From 81987af64411cd4f4d58eb5f9a1afda1597d4887 Mon Sep 17 00:00:00 2001 From: Dotta Date: Wed, 28 Oct 2026 07:41:33 -0500 Subject: [PATCH] Accept lifecycle-ready running workers in catalog readiness --- tests/runner-e2e/chat-catalog-contract.ts | 6 ++++ tests/runner-e2e/chat-catalog-flow.ts | 4 +-- .../runner-e2e/chat-catalog-readiness.test.ts | 32 +++++++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 tests/runner-e2e/chat-catalog-readiness.test.ts diff --git a/tests/runner-e2e/chat-catalog-contract.ts b/tests/runner-e2e/chat-catalog-contract.ts index 66c536ef249..5eefd3c1973 100644 --- a/tests/runner-e2e/chat-catalog-contract.ts +++ b/tests/runner-e2e/chat-catalog-contract.ts @@ -3,6 +3,12 @@ import type { CredentialName } from "./types.js"; import type { ChatExternalInputRequirement } from "./chat-private-inputs.js"; import type { ChatScenario } from "./chat-feature-cases.js"; +/** Lifecycle readiness permits an already-busy worker; this is not run completion proof. */ +export function chatCatalogAgentIsReady(value: { lifecycleState?: string | null; status?: string | null; lifecycleError?: unknown }): boolean { + return value.lifecycleState === "ready" && !value.lifecycleError + && ["idle", "active", "running"].includes(value.status ?? ""); +} + export interface ChatCatalogCheck { id: string; passed: boolean; detail: string } export interface ChatCatalogGrade { passed: boolean; checks: ChatCatalogCheck[] } export interface ChatCatalogContext { diff --git a/tests/runner-e2e/chat-catalog-flow.ts b/tests/runner-e2e/chat-catalog-flow.ts index 7d9a774ac14..1cafebfa9de 100644 --- a/tests/runner-e2e/chat-catalog-flow.ts +++ b/tests/runner-e2e/chat-catalog-flow.ts @@ -1,4 +1,4 @@ -import { chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "./chat-catalog-contract.js"; +import { chatCatalogAgentIsReady, chatCatalogNativePhaseIsTimely, chatCatalogNativePhaseRemainingBudget } from "./chat-catalog-contract.js"; import { chatNativeTimeoutEvidence } from "./chat-native-timeout-policy.js"; import { completeNativeSlackFileConditional, dispatchNativeSlackIngress, prepareNativeSlackOtherThread, slackOtherThreadBoundaryMatches, slackNativeRunCheck } from "./chat-slack-ingress.js"; import { runScopedCompanionSurface } from "./chat-companion-surface.js"; @@ -93,7 +93,7 @@ export async function runChatCatalogFlow(context: ChatCatalogContext) { if (implementation.setupPolicy?.readiness !== "preserve-scenario") for (const agent of agents) await pollUntil({ label: `catalog agent readiness ${agent.id}`, deadlineAt: Math.min(deadlineAt, Date.now() + 45_000), intervalMs: 500, load: () => input.api.get<{ lifecycleState: string; status: string; lifecycleError?: string }>(`/api/agents/${agent.id}`), - accept: value => value.lifecycleState === "ready" && ["idle", "active"].includes(value.status), + accept: chatCatalogAgentIsReady, reject: value => value.lifecycleError ? "Agent setup failed; diagnostic details withheld" : undefined, }); const scopes = [{ scopeType: "company", scopeId: input.fixtures.company.id }, diff --git a/tests/runner-e2e/chat-catalog-readiness.test.ts b/tests/runner-e2e/chat-catalog-readiness.test.ts new file mode 100644 index 00000000000..832f6b28d69 --- /dev/null +++ b/tests/runner-e2e/chat-catalog-readiness.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, it } from "vitest"; +import { chatCatalogAgentIsReady } from "./chat-catalog-contract.js"; +import { chatCatalogRunStatusAllowed } from "./chat-catalog-run-policy.js"; +import { pollUntil } from "./api.js"; + +describe("catalog agent lifecycle readiness", () => { + it.each(["idle", "active", "running"])("accepts a lifecycle-ready %s agent", status => { + expect(chatCatalogAgentIsReady({ lifecycleState: "ready", status, lifecycleError: null })).toBe(true); + }); + it.each(["paused", "error", "terminated", "pending_approval", "verifying", "failed", ""])("rejects non-ready operational status %s", status => { + expect(chatCatalogAgentIsReady({ lifecycleState: "ready", status })).toBe(false); + }); + it.each(["verifying", "pending", "failed", "paused", undefined, null])("rejects unready lifecycle %s even while running", lifecycleState => { + expect(chatCatalogAgentIsReady({ lifecycleState, status: "running" })).toBe(false); + }); + it("rejects lifecycle errors even when the status says ready and running", () => { + expect(chatCatalogAgentIsReady({ lifecycleState: "ready", status: "running", lifecycleError: "failure" })).toBe(false); + expect(chatCatalogAgentIsReady({ lifecycleState: "ready" })).toBe(false); + }); + it("the actual polling helper accepts a ready busy worker without another readiness request", async () => { + let reads = 0; + const actual = { id: "held-worker", lifecycleState: "ready", status: "running", lifecycleError: null }; + expect(await pollUntil({ label: "held worker readiness", deadlineAt: Date.now() + 45_000, + load: async () => { reads++; return actual; }, accept: chatCatalogAgentIsReady })).toBe(actual); + expect(reads).toBe(1); + }); + it("accepting a busy agent does not accept a running canonical native as a completed run", () => { + expect(chatCatalogAgentIsReady({ lifecycleState: "ready", status: "running" })).toBe(true); + expect(chatCatalogRunStatusAllowed({ implementation: {}, run: { status: "running" } as never, + companyId: "company", leadId: "lead" })).toBe(false); + }); +});