Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 11b863220d | |||
| e1251fae2d | |||
| d86062b321 |
@@ -68,7 +68,89 @@ const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["failed", "cancelled", "ti
|
|||||||
export const ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000;
|
export const ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS = 60 * 60 * 1000;
|
||||||
export const ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000;
|
export const ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS = 4 * 60 * 60 * 1000;
|
||||||
export const ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS = 30 * 60 * 1000;
|
export const ACTIVE_RUN_OUTPUT_CONTINUE_REARM_MS = 30 * 60 * 1000;
|
||||||
|
// ORA-289: runs younger than this cannot have legitimately produced output yet.
|
||||||
|
// Stale-run evaluation ignores them regardless of log size, so the 0-byte /
|
||||||
|
// <200-byte log heuristic no longer fires class-1 false positives.
|
||||||
|
export const ACTIVE_RUN_OUTPUT_MIN_AGE_MS = 30 * 1000;
|
||||||
|
// ORA-289: a run older than this with a 0-byte / <200-byte log and no spawn
|
||||||
|
// PID or no in-memory process handle is treated as a class-2 stall / zombie
|
||||||
|
// even before the general suspicion threshold (60m) trips. Lets the watchdog
|
||||||
|
// surface the ORA-284 / ORA-275 / ORA-276 family of stalls at the operator's
|
||||||
|
// desired SLA instead of waiting an hour.
|
||||||
|
export const ACTIVE_RUN_OUTPUT_LIKELY_ZOMBIE_THRESHOLD_MS = 5 * 60 * 1000;
|
||||||
|
// ORA-289: any log strictly below this byte count is treated as "near-empty"
|
||||||
|
// for the likely-zombie/stall detection branch. Matches the bootstrap-only
|
||||||
|
// `<200-byte` heuristic called out in the issue evidence.
|
||||||
|
export const ACTIVE_RUN_OUTPUT_NEAR_EMPTY_BYTES = 200;
|
||||||
const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024;
|
const ACTIVE_RUN_OUTPUT_EVIDENCE_TAIL_BYTES = 8 * 1024;
|
||||||
|
|
||||||
|
export type StaleRunEvaluationDecision =
|
||||||
|
| {
|
||||||
|
kind: "evaluate";
|
||||||
|
level: "suspicious" | "critical";
|
||||||
|
reason: "above_suspicion_threshold" | "above_critical_threshold" | "likely_zombie_or_stall";
|
||||||
|
elapsedMs: number;
|
||||||
|
wallClockSec: number;
|
||||||
|
}
|
||||||
|
| {
|
||||||
|
kind: "skip";
|
||||||
|
reason: "no_silence_anchor" | "too_young" | "below_threshold";
|
||||||
|
elapsedMs: number | null;
|
||||||
|
wallClockSec: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type StaleRunEvaluationInput = {
|
||||||
|
silenceAgeMs: number | null;
|
||||||
|
logBytes: number | null;
|
||||||
|
hasSpawnPid: boolean;
|
||||||
|
hasInMemoryHandle: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function decideStaleRunEvaluation(input: StaleRunEvaluationInput): StaleRunEvaluationDecision {
|
||||||
|
if (input.silenceAgeMs == null) {
|
||||||
|
return { kind: "skip", reason: "no_silence_anchor", elapsedMs: null, wallClockSec: null };
|
||||||
|
}
|
||||||
|
const elapsedMs = Math.max(0, Math.floor(input.silenceAgeMs));
|
||||||
|
const wallClockSec = Math.floor(elapsedMs / 1000);
|
||||||
|
if (elapsedMs < ACTIVE_RUN_OUTPUT_MIN_AGE_MS) {
|
||||||
|
return { kind: "skip", reason: "too_young", elapsedMs, wallClockSec };
|
||||||
|
}
|
||||||
|
const logBytes = input.logBytes ?? 0;
|
||||||
|
const nearEmpty = logBytes < ACTIVE_RUN_OUTPUT_NEAR_EMPTY_BYTES;
|
||||||
|
const noProcessAnchor = !input.hasSpawnPid || !input.hasInMemoryHandle;
|
||||||
|
if (
|
||||||
|
elapsedMs >= ACTIVE_RUN_OUTPUT_LIKELY_ZOMBIE_THRESHOLD_MS &&
|
||||||
|
nearEmpty &&
|
||||||
|
noProcessAnchor
|
||||||
|
) {
|
||||||
|
return {
|
||||||
|
kind: "evaluate",
|
||||||
|
level: "suspicious",
|
||||||
|
reason: "likely_zombie_or_stall",
|
||||||
|
elapsedMs,
|
||||||
|
wallClockSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (elapsedMs >= ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS) {
|
||||||
|
return {
|
||||||
|
kind: "evaluate",
|
||||||
|
level: "critical",
|
||||||
|
reason: "above_critical_threshold",
|
||||||
|
elapsedMs,
|
||||||
|
wallClockSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if (elapsedMs >= ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS) {
|
||||||
|
return {
|
||||||
|
kind: "evaluate",
|
||||||
|
level: "suspicious",
|
||||||
|
reason: "above_suspicion_threshold",
|
||||||
|
elapsedMs,
|
||||||
|
wallClockSec,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
return { kind: "skip", reason: "below_threshold", elapsedMs, wallClockSec };
|
||||||
|
}
|
||||||
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery;
|
const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery;
|
||||||
const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
|
const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation;
|
||||||
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext";
|
||||||
@@ -1446,6 +1528,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
evidence: Awaited<ReturnType<typeof collectStaleRunEvidence>>;
|
evidence: Awaited<ReturnType<typeof collectStaleRunEvidence>>;
|
||||||
level: "suspicious" | "critical";
|
level: "suspicious" | "critical";
|
||||||
now: Date;
|
now: Date;
|
||||||
|
decision?: StaleRunEvaluationDecision;
|
||||||
}) {
|
}) {
|
||||||
const sourceIssue = input.sourceIssue
|
const sourceIssue = input.sourceIssue
|
||||||
? issueUiLink({ identifier: input.sourceIssue.identifier, id: input.sourceIssue.id }, input.prefix)
|
? issueUiLink({ identifier: input.sourceIssue.identifier, id: input.sourceIssue.id }, input.prefix)
|
||||||
@@ -1465,6 +1548,18 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
`- ${issueUiLink({ identifier: issue.identifier, id: issue.id }, input.prefix)} \`${issue.status}\`: ${issue.title}`,
|
`- ${issueUiLink({ identifier: issue.identifier, id: issue.id }, input.prefix)} \`${issue.status}\`: ${issue.title}`,
|
||||||
).join("\n")
|
).join("\n")
|
||||||
: "- none detected";
|
: "- none detected";
|
||||||
|
const wallClockLine =
|
||||||
|
input.decision && input.decision.kind === "evaluate"
|
||||||
|
? `- wallClockSec: ${input.decision.wallClockSec}`
|
||||||
|
: `- wallClockSec: ${input.evidence.silenceAgeMs == null ? "unknown" : Math.floor(input.evidence.silenceAgeMs / 1000)}`;
|
||||||
|
const elapsedLine =
|
||||||
|
input.decision && input.decision.kind === "evaluate"
|
||||||
|
? `- elapsedMs: ${input.decision.elapsedMs}`
|
||||||
|
: `- elapsedMs: ${input.evidence.silenceAgeMs ?? "unknown"}`;
|
||||||
|
const thresholdLine =
|
||||||
|
input.decision && input.decision.kind === "evaluate"
|
||||||
|
? `- Threshold applied: ${input.decision.reason}`
|
||||||
|
: null;
|
||||||
return [
|
return [
|
||||||
`Paperclip detected ${input.level} output silence on an active heartbeat run.`,
|
`Paperclip detected ${input.level} output silence on an active heartbeat run.`,
|
||||||
"",
|
"",
|
||||||
@@ -1479,7 +1574,10 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
`- Last output at: ${input.run.lastOutputAt?.toISOString() ?? "none recorded"}`,
|
`- Last output at: ${input.run.lastOutputAt?.toISOString() ?? "none recorded"}`,
|
||||||
`- Last output sequence: ${input.run.lastOutputSeq ?? 0}`,
|
`- Last output sequence: ${input.run.lastOutputSeq ?? 0}`,
|
||||||
`- Silent for: ${formatDuration(input.evidence.silenceAgeMs)}`,
|
`- Silent for: ${formatDuration(input.evidence.silenceAgeMs)}`,
|
||||||
`- Thresholds: suspicious after ${formatDuration(ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS)}, critical after ${formatDuration(ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS)}`,
|
wallClockLine,
|
||||||
|
elapsedLine,
|
||||||
|
thresholdLine,
|
||||||
|
`- Thresholds: suspicious after ${formatDuration(ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS)}, critical after ${formatDuration(ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS)}, likely zombie/stall after ${formatDuration(ACTIVE_RUN_OUTPUT_LIKELY_ZOMBIE_THRESHOLD_MS)} (min run age ${formatDuration(ACTIVE_RUN_OUTPUT_MIN_AGE_MS)})`,
|
||||||
`- Process metadata: pid \`${input.run.processPid ?? "unknown"}\`, process group \`${input.run.processGroupId ?? "unknown"}\`, in-memory handle \`${runningProcesses.has(input.run.id) ? "yes" : "no"}\``,
|
`- Process metadata: pid \`${input.run.processPid ?? "unknown"}\`, process group \`${input.run.processGroupId ?? "unknown"}\`, in-memory handle \`${runningProcesses.has(input.run.id) ? "yes" : "no"}\``,
|
||||||
"",
|
"",
|
||||||
"## Last Output Excerpt",
|
"## Last Output Excerpt",
|
||||||
@@ -1699,7 +1797,37 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
prefix,
|
prefix,
|
||||||
now: input.now,
|
now: input.now,
|
||||||
});
|
});
|
||||||
const level = (evidence.silenceAgeMs ?? 0) >= ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS ? "critical" : "suspicious";
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: evidence.silenceAgeMs,
|
||||||
|
logBytes: input.run.logBytes ?? null,
|
||||||
|
hasSpawnPid: input.run.processPid != null,
|
||||||
|
hasInMemoryHandle: runningProcesses.has(input.run.id),
|
||||||
|
});
|
||||||
|
if (decision.kind === "skip" && !existing) {
|
||||||
|
await logActivity(db, {
|
||||||
|
companyId: input.run.companyId,
|
||||||
|
actorType: "system",
|
||||||
|
actorId: "system",
|
||||||
|
agentId: input.run.agentId,
|
||||||
|
runId: input.run.id,
|
||||||
|
action: "heartbeat.output_stale_evaluation_skipped",
|
||||||
|
entityType: "heartbeat_run",
|
||||||
|
entityId: input.run.id,
|
||||||
|
details: {
|
||||||
|
source: "recovery.scan_silent_active_runs",
|
||||||
|
reason: decision.reason,
|
||||||
|
elapsedMs: decision.elapsedMs,
|
||||||
|
wallClockSec: decision.wallClockSec,
|
||||||
|
logBytes: input.run.logBytes ?? null,
|
||||||
|
hasSpawnPid: input.run.processPid != null,
|
||||||
|
hasInMemoryHandle: runningProcesses.has(input.run.id),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
return { kind: "skipped" as const };
|
||||||
|
}
|
||||||
|
const level = decision.kind === "evaluate"
|
||||||
|
? decision.level
|
||||||
|
: (evidence.silenceAgeMs ?? 0) >= ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS ? "critical" : "suspicious";
|
||||||
if (existing) {
|
if (existing) {
|
||||||
if (level === "critical" && existing.priority !== "high") {
|
if (level === "critical" && existing.priority !== "high") {
|
||||||
await issuesSvc.update(existing.id, {
|
await issuesSvc.update(existing.id, {
|
||||||
@@ -1710,8 +1838,14 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
"",
|
"",
|
||||||
`- Run: \`${input.run.id}\``,
|
`- Run: \`${input.run.id}\``,
|
||||||
`- Silent for: ${formatDuration(evidence.silenceAgeMs)}`,
|
`- Silent for: ${formatDuration(evidence.silenceAgeMs)}`,
|
||||||
|
decision.kind === "evaluate"
|
||||||
|
? `- wallClockSec: ${decision.wallClockSec}`
|
||||||
|
: null,
|
||||||
|
decision.kind === "evaluate"
|
||||||
|
? `- elapsedMs: ${decision.elapsedMs}`
|
||||||
|
: null,
|
||||||
`- Last output at: ${input.run.lastOutputAt?.toISOString() ?? "none recorded"}`,
|
`- Last output at: ${input.run.lastOutputAt?.toISOString() ?? "none recorded"}`,
|
||||||
].join("\n"), { runId: input.run.id });
|
].filter((line): line is string => line != null).join("\n"), { runId: input.run.id });
|
||||||
await ensureSourceIssueCommentedForStaleEvaluation({
|
await ensureSourceIssueCommentedForStaleEvaluation({
|
||||||
sourceIssue,
|
sourceIssue,
|
||||||
evaluationIssue: existing,
|
evaluationIssue: existing,
|
||||||
@@ -1738,6 +1872,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup })
|
|||||||
evidence,
|
evidence,
|
||||||
level,
|
level,
|
||||||
now: input.now,
|
now: input.now,
|
||||||
|
decision: decision.kind === "evaluate" ? decision : undefined,
|
||||||
});
|
});
|
||||||
let evaluation: Awaited<ReturnType<typeof issuesSvc.create>>;
|
let evaluation: Awaited<ReturnType<typeof issuesSvc.create>>;
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,173 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS,
|
||||||
|
ACTIVE_RUN_OUTPUT_LIKELY_ZOMBIE_THRESHOLD_MS,
|
||||||
|
ACTIVE_RUN_OUTPUT_MIN_AGE_MS,
|
||||||
|
ACTIVE_RUN_OUTPUT_NEAR_EMPTY_BYTES,
|
||||||
|
ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS,
|
||||||
|
decideStaleRunEvaluation,
|
||||||
|
} from "./service.js";
|
||||||
|
|
||||||
|
const MIN_AGE_MS = ACTIVE_RUN_OUTPUT_MIN_AGE_MS;
|
||||||
|
const ZOMBIE_MS = ACTIVE_RUN_OUTPUT_LIKELY_ZOMBIE_THRESHOLD_MS;
|
||||||
|
const SUSPICION_MS = ACTIVE_RUN_OUTPUT_SUSPICION_THRESHOLD_MS;
|
||||||
|
const CRITICAL_MS = ACTIVE_RUN_OUTPUT_CRITICAL_THRESHOLD_MS;
|
||||||
|
const NEAR_EMPTY = ACTIVE_RUN_OUTPUT_NEAR_EMPTY_BYTES;
|
||||||
|
|
||||||
|
describe("decideStaleRunEvaluation (ORA-289)", () => {
|
||||||
|
it("skips runs without a silence anchor", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: null,
|
||||||
|
logBytes: 0,
|
||||||
|
hasSpawnPid: false,
|
||||||
|
hasInMemoryHandle: false,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("skip");
|
||||||
|
if (decision.kind === "skip") {
|
||||||
|
expect(decision.reason).toBe("no_silence_anchor");
|
||||||
|
expect(decision.elapsedMs).toBeNull();
|
||||||
|
expect(decision.wallClockSec).toBeNull();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores runs younger than 30s even when the log is empty", () => {
|
||||||
|
const oneSecondOld = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: 1_000,
|
||||||
|
logBytes: 0,
|
||||||
|
hasSpawnPid: false,
|
||||||
|
hasInMemoryHandle: false,
|
||||||
|
});
|
||||||
|
expect(oneSecondOld.kind).toBe("skip");
|
||||||
|
if (oneSecondOld.kind === "skip") {
|
||||||
|
expect(oneSecondOld.reason).toBe("too_young");
|
||||||
|
expect(oneSecondOld.elapsedMs).toBe(1_000);
|
||||||
|
expect(oneSecondOld.wallClockSec).toBe(1);
|
||||||
|
}
|
||||||
|
|
||||||
|
const twentyNineSecondOld = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: MIN_AGE_MS - 1,
|
||||||
|
logBytes: 0,
|
||||||
|
hasSpawnPid: false,
|
||||||
|
hasInMemoryHandle: false,
|
||||||
|
});
|
||||||
|
expect(twentyNineSecondOld.kind).toBe("skip");
|
||||||
|
if (twentyNineSecondOld.kind === "skip") {
|
||||||
|
expect(twentyNineSecondOld.reason).toBe("too_young");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("ignores runs younger than 30s even when the log has bootstrap bytes", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: 5_000,
|
||||||
|
logBytes: 180,
|
||||||
|
hasSpawnPid: true,
|
||||||
|
hasInMemoryHandle: true,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("skip");
|
||||||
|
if (decision.kind === "skip") {
|
||||||
|
expect(decision.reason).toBe("too_young");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a >5 min run with 0-byte log and no spawn PID as a likely zombie", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: ZOMBIE_MS + 30_000,
|
||||||
|
logBytes: 0,
|
||||||
|
hasSpawnPid: false,
|
||||||
|
hasInMemoryHandle: true,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("evaluate");
|
||||||
|
if (decision.kind === "evaluate") {
|
||||||
|
expect(decision.level).toBe("suspicious");
|
||||||
|
expect(decision.reason).toBe("likely_zombie_or_stall");
|
||||||
|
expect(decision.wallClockSec).toBe(Math.floor((ZOMBIE_MS + 30_000) / 1000));
|
||||||
|
expect(decision.elapsedMs).toBe(ZOMBIE_MS + 30_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a >5 min run with <200-byte log and no in-memory handle as a likely stall", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: 6 * 60_000,
|
||||||
|
logBytes: 120,
|
||||||
|
hasSpawnPid: true,
|
||||||
|
hasInMemoryHandle: false,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("evaluate");
|
||||||
|
if (decision.kind === "evaluate") {
|
||||||
|
expect(decision.level).toBe("suspicious");
|
||||||
|
expect(decision.reason).toBe("likely_zombie_or_stall");
|
||||||
|
expect(decision.wallClockSec).toBe(360);
|
||||||
|
expect(decision.elapsedMs).toBe(6 * 60_000);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fire the likely-zombie branch when log bytes are at/above the near-empty boundary", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: 7 * 60_000,
|
||||||
|
logBytes: NEAR_EMPTY,
|
||||||
|
hasSpawnPid: false,
|
||||||
|
hasInMemoryHandle: false,
|
||||||
|
});
|
||||||
|
// Above the near-empty boundary, the heuristic defers to the general
|
||||||
|
// suspicion threshold; 7 min is below 60 min so we skip with below_threshold.
|
||||||
|
expect(decision.kind).toBe("skip");
|
||||||
|
if (decision.kind === "skip") {
|
||||||
|
expect(decision.reason).toBe("below_threshold");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("does NOT fire the likely-zombie branch when both process anchors are present", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: 7 * 60_000,
|
||||||
|
logBytes: 0,
|
||||||
|
hasSpawnPid: true,
|
||||||
|
hasInMemoryHandle: true,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("skip");
|
||||||
|
if (decision.kind === "skip") {
|
||||||
|
expect(decision.reason).toBe("below_threshold");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still files the general suspicious case after 60 min of silence", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: SUSPICION_MS + 1_000,
|
||||||
|
logBytes: 4_096,
|
||||||
|
hasSpawnPid: true,
|
||||||
|
hasInMemoryHandle: true,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("evaluate");
|
||||||
|
if (decision.kind === "evaluate") {
|
||||||
|
expect(decision.level).toBe("suspicious");
|
||||||
|
expect(decision.reason).toBe("above_suspicion_threshold");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("escalates to critical once the run crosses 4 h of silence", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: CRITICAL_MS + 1_000,
|
||||||
|
logBytes: 4_096,
|
||||||
|
hasSpawnPid: true,
|
||||||
|
hasInMemoryHandle: true,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("evaluate");
|
||||||
|
if (decision.kind === "evaluate") {
|
||||||
|
expect(decision.level).toBe("critical");
|
||||||
|
expect(decision.reason).toBe("above_critical_threshold");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("reports wallClockSec / elapsedMs in the evaluate decision so the operator can confirm the threshold", () => {
|
||||||
|
const decision = decideStaleRunEvaluation({
|
||||||
|
silenceAgeMs: 6 * 60_000 + 500,
|
||||||
|
logBytes: 0,
|
||||||
|
hasSpawnPid: false,
|
||||||
|
hasInMemoryHandle: false,
|
||||||
|
});
|
||||||
|
expect(decision.kind).toBe("evaluate");
|
||||||
|
if (decision.kind === "evaluate") {
|
||||||
|
expect(decision.elapsedMs).toBe(6 * 60_000 + 500);
|
||||||
|
expect(decision.wallClockSec).toBe(360);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user