From 7724edbdd3fa0acd94c8210a30dceba501fcdbe8 Mon Sep 17 00:00:00 2001 From: Saim Shafique Date: Mon, 12 Oct 2026 21:15:12 +0500 Subject: [PATCH] fix(process): report single empty settlement observation as unverifiable (#2232) observeSettlement classified a group as alive_at_deadline when the settlement budget expired after one empty observation, though no live member was ever seen. Track observed liveness and return unverifiable when the deadline ends observation with only empty rounds. The two-consecutive-empty rule for quiesced is unchanged, and the cleanup_outcome projection for non-quiesced settlements stays cleaned. Fixes #2170. --- .../capture/ProcessCaptureLifecycle.ts | 12 ++- .../process/processSettlementDeadline.test.ts | 73 +++++++++++++++++++ 2 files changed, 83 insertions(+), 2 deletions(-) create mode 100644 tests/boundary/process/processSettlementDeadline.test.ts diff --git a/src/process/capture/ProcessCaptureLifecycle.ts b/src/process/capture/ProcessCaptureLifecycle.ts index ec31113ee..2d2fcec00 100644 --- a/src/process/capture/ProcessCaptureLifecycle.ts +++ b/src/process/capture/ProcessCaptureLifecycle.ts @@ -662,6 +662,7 @@ export const observeSettlement = async ( } const started = Date.now(); let consecutiveEmpty = 0; + let sawLive = false; let deadlineReached = false; while (!deadlineReached) { onLiveProgress?.(); @@ -685,13 +686,20 @@ export const observeSettlement = async ( recordEvent("lifecycle", 1); return { state: "quiesced", elapsed_ms: Date.now() - started }; } - } else consecutiveEmpty = 0; + } else { + consecutiveEmpty = 0; + sawLive = true; + } deadlineReached = Date.now() - started >= settleMs; if (deadlineReached) break; await delay(50, undefined, { signal }); } recordEvent("lifecycle", 1); - return { state: "alive_at_deadline", elapsed_ms: Date.now() - started }; + // A lone empty observation proves neither liveness nor quiescence; only an + // observed live member justifies alive_at_deadline. + return sawLive + ? { state: "alive_at_deadline", elapsed_ms: Date.now() - started } + : { state: "unverifiable", elapsed_ms: Date.now() - started }; }; /** Name why an attempt is unverified; a blank message never reaches the capture schema. */ diff --git a/tests/boundary/process/processSettlementDeadline.test.ts b/tests/boundary/process/processSettlementDeadline.test.ts new file mode 100644 index 000000000..83c748366 --- /dev/null +++ b/tests/boundary/process/processSettlementDeadline.test.ts @@ -0,0 +1,73 @@ +import { spawn } from "node:child_process"; +import { describe, expect, it } from "vitest"; +import { observeSettlement } from "../../../src/process/capture/ProcessCaptureLifecycle.js"; +import { observeOwnedProcessGroups } from "../../../src/process/ProcessOwnershipObservation.js"; + +/** Process group id that cannot exist on any supported host. */ +const ABSENT_GROUP_ID = 4_294_967_295; + +const observeGroup = async (groupId: number) => + observeOwnedProcessGroups("settlement-deadline-probe", [groupId]); + +describe("settlement deadline classification", () => { + it("does not report a single empty observation as alive_at_deadline", async () => { + expect( + (await observeGroup(ABSENT_GROUP_ID)).get(ABSENT_GROUP_ID)?.state, + ).toBe("empty"); + + const settlement = await observeSettlement( + "settlement-single-empty", + [ABSENT_GROUP_ID], + 0, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("unverifiable"); + }); + + it("still reports observed liveness as alive_at_deadline", async () => { + const runId = "settlement-live-group"; + // A detached child leads its own process group and carries the run token, + // so the group observes as a genuinely owned live member. + const child = spawn("sleep", ["30"], { + detached: true, + stdio: "ignore", + env: { ...process.env, REA_PROCESS_RUN_ID: runId }, + }); + child.unref(); + try { + // Wait until the child is actually listed before asserting on it. + const started = Date.now(); + while ( + (await observeOwnedProcessGroups(runId, [child.pid!])).get(child.pid!) + ?.state !== "alive" + ) { + if (Date.now() - started > 5_000) + throw new Error("live fixture group never observed as alive"); + await new Promise((resolve) => setTimeout(resolve, 50)); + } + + const settlement = await observeSettlement( + runId, + [child.pid!], + 0, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("alive_at_deadline"); + } finally { + child.kill("SIGKILL"); + } + }); + + it("still reports two consecutive empty observations as quiesced", async () => { + const settlement = await observeSettlement( + "settlement-two-empty", + [ABSENT_GROUP_ID], + 1_000, + () => undefined, + "linux", + ); + expect(settlement.state).toBe("quiesced"); + }); +});