mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-05 12:29:37 +01:00
Merge current master lifecycle and CI fixes into Claude behavior
Co-Authored-By: Paperclip <noreply@paperclip.ing> * codex/claude-runner-protocol: fix(grok): avoid routine wakes for proactive admission (#16422) feat(ci): profile full CODEOWNER PR suites in Honeycomb (#16378) test: bound command snapshot forwarding checks (#16421) fix(agents): report actionable lifecycle failure steps (#16418)
This commit is contained in:
commit
bd3ceab2b2
50 files changed
+1630
-60
No files matched your search
@@ -5,6 +5,7 @@ import type { LifecycleAgent, LifecycleStore, LifecycleDriver } from "../applica
|
||||
import { startAgentLifecycle } from "../index.js";
|
||||
import { createAgentLifecycleEffects } from "../../../services/agent-lifecycle.js";
|
||||
import { logger } from "../../../middleware/logger.js";
|
||||
import { lifecycleFailureMessage } from "../application/failure.js";
|
||||
|
||||
const agent: LifecycleAgent = { id: "agent", companyId: "company", lifecycleState: "verifying", lifecycleVersion: 3,
|
||||
lifecycleRequiredPluginIds: null, lifecycleError: null,
|
||||
@@ -66,6 +67,30 @@ describe("lifecycle readiness handoff", () => {
|
||||
});
|
||||
|
||||
describe("lifecycle failure diagnostics", () => {
|
||||
it.each([
|
||||
["host", "harness_model_unavailable", "Execution configuration check failed. The selected model is unavailable."],
|
||||
["plugin", "required_plugin_unavailable", "Execution configuration check: required plugin step failed. A required plugin is unavailable."],
|
||||
["select_plugins", "unknown private code", "Execution configuration check: finding required plugins failed. Paperclip could not complete this step."],
|
||||
["transition", "40P01", "Execution configuration check: saving progress failed. Paperclip could not complete this step."],
|
||||
] as const)("persists safe actionable errors for %s", async (stage, code, expected) => {
|
||||
const { store, driver, worker } = fixture();
|
||||
const error = Object.assign(new Error("secret provider output"), { code });
|
||||
if (stage === "host") driver.runHost.mockRejectedValue(error);
|
||||
if (stage === "plugin") driver.runPlugin.mockRejectedValue(error);
|
||||
if (stage === "select_plugins") driver.requiredPluginIds.mockRejectedValue(error);
|
||||
if (stage === "transition") store.change.mockRejectedValue(error);
|
||||
await worker.process(agent.id);
|
||||
expect(store.defer).toHaveBeenCalledWith(agent, expect.any(String), expect.stringContaining(expected), expect.any(Date));
|
||||
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("secret provider output");
|
||||
expect(JSON.stringify(store.defer.mock.calls)).not.toContain("unknown private code");
|
||||
await worker.stop();
|
||||
});
|
||||
|
||||
it("does not treat prototype properties as allowed failure codes", () => {
|
||||
expect(lifecycleFailureMessage({ stage: "host", phase: "verifying" }, { code: "toString" }))
|
||||
.toContain("Paperclip could not complete this step");
|
||||
});
|
||||
|
||||
it.each(["host", "plugin", "claim", "renew", "scan", "defer"] as const)("reports %s failures with the available context", async stage => {
|
||||
const { store, driver, report, worker } = fixture();
|
||||
const error = new Error("private provider detail");
|
||||
@@ -100,6 +125,7 @@ describe("lifecycle failure diagnostics", () => {
|
||||
it.each([
|
||||
[{ code: "required_plugin_unavailable" }, "required_plugin_unavailable"],
|
||||
[{ code: "harness_test_failed" }, "harness_test_failed"],
|
||||
[{ code: "harness_auth_failed" }, "harness_auth_failed"],
|
||||
[{ cause: { code: "40P01", message: "private query" } }, "40P01"],
|
||||
[{ code: "private credential value" }, "unclassified"],
|
||||
])("logs known codes without provider error contents", (details, failureCode) => {
|
||||
|
||||
@@ -140,7 +140,7 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
const work = worker(createLifecycleDriver(db, {} as never).runHost);
|
||||
await work.process(agent.id);
|
||||
expect(probe).toHaveBeenCalled();
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", lifecycleError: "The lifecycle step failed. Retry the operation." });
|
||||
expect(await current(agent.id)).toMatchObject({ lifecycleState: "verifying", lifecycleError: expect.stringContaining("Execution configuration check failed. Authentication failed.") });
|
||||
} finally { probe.mockRestore(); }
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
import type { LifecycleFailure } from "./ports.js";
|
||||
|
||||
// Only fixed text may leave this boundary. Provider errors can contain secrets.
|
||||
const reasons = {
|
||||
harness_test_failed: "The execution test did not pass. Open Configuration and run the agent test to inspect the failed checks.",
|
||||
harness_auth_failed: "Authentication failed. Check the agent's AI connection or sign in to its execution tool, then retry.",
|
||||
harness_model_unavailable: "The selected model is unavailable. Choose a model supported by the agent's AI connection in Configuration, then retry.",
|
||||
harness_command_unavailable: "The execution command was not found. Check the command and its installation in the agent's environment, then retry.",
|
||||
harness_cwd_invalid: "The working directory is unavailable. Check the working directory in Configuration, then retry.",
|
||||
harness_probe_timed_out: "The execution test timed out. Paperclip will retry automatically. If this continues, run the agent test in Configuration.",
|
||||
harness_probe_unexpected_output: "The execution test did not return the expected response. Run the agent test in Configuration to inspect the failed checks.",
|
||||
test_environment_unavailable: "The test environment is unavailable. Check the agent's environment in Configuration. Paperclip will retry automatically.",
|
||||
required_plugin_unavailable: "A required plugin is unavailable. Check the plugin's status in Settings. Paperclip will retry automatically.",
|
||||
invalid_lifecycle_result: "A required plugin returned an invalid result. Check the plugin's status in Settings. Paperclip will retry automatically.",
|
||||
} as const;
|
||||
|
||||
const transientCodes = new Set(["ECONNREFUSED", "ECONNRESET", "ETIMEDOUT", "40001", "40P01", "53300", "57P01"]);
|
||||
|
||||
export function lifecycleFailureCode(error: unknown): string | undefined {
|
||||
const candidate = error as { code?: unknown; cause?: { code?: unknown } } | null;
|
||||
return [candidate?.code, candidate?.cause?.code].find((code): code is string =>
|
||||
typeof code === "string" && (Object.hasOwn(reasons, code) || transientCodes.has(code)));
|
||||
}
|
||||
|
||||
export function lifecycleFailureMessage(context: LifecycleFailure, error: unknown): string {
|
||||
const phases = {
|
||||
preparing: "Agent preparation", verifying: "Execution configuration check", pausing: "Stopping agent execution",
|
||||
resuming: "Starting agent resources", terminating: "Stopping agent execution", cleaning_up: "Agent resource cleanup",
|
||||
};
|
||||
const phase = context.phase && Object.hasOwn(phases, context.phase)
|
||||
? phases[context.phase as keyof typeof phases] : "Agent setup";
|
||||
const step = context.stage === "plugin" ? `${phase}: required plugin step`
|
||||
: context.stage === "select_plugins" ? `${phase}: finding required plugins`
|
||||
: ["save_plugins", "complete_host", "complete_plugin", "transition"].includes(context.stage)
|
||||
? `${phase}: saving progress` : phase;
|
||||
const code = lifecycleFailureCode(error);
|
||||
const reason = code && Object.hasOwn(reasons, code) ? reasons[code as keyof typeof reasons]
|
||||
: "Paperclip could not complete this step. It will retry automatically. If this continues, contact your instance administrator.";
|
||||
return `${step} failed. ${reason}`;
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { LifecycleDriver, LifecycleStore, LifecycleFailure } from "./ports.js";
|
||||
import { isLifecycleWorkPending } from "../domain/policy.js";
|
||||
import { lifecycleFailureMessage } from "./failure.js";
|
||||
|
||||
/** Database leases protect each attempt across server processes. */
|
||||
export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDriver, reportFailure: (context: LifecycleFailure, error: unknown) => void, canRun = () => true) {
|
||||
@@ -20,10 +21,13 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
|
||||
if (!agent || !isLifecycleWorkPending(agent.lifecycleState)) return;
|
||||
const context = { agentId: agent.id, companyId: agent.companyId, phase: agent.lifecycleState,
|
||||
operationId: agent.lifecycleOperation?.id, version: agent.lifecycleVersion };
|
||||
const runStep = <T>(stage: LifecycleFailure["stage"], work: () => Promise<T>, pluginId?: string) =>
|
||||
attempt({ ...context, stage, ...(pluginId ? { pluginId } : {}) }, work);
|
||||
let failureContext: LifecycleFailure = { ...context, stage: "host" };
|
||||
const runStep = <T>(stage: LifecycleFailure["stage"], work: () => Promise<T>, pluginId?: string) => {
|
||||
const stepContext = { ...context, stage, ...(pluginId ? { pluginId } : {}) };
|
||||
return attempt(stepContext, work).catch(error => { failureContext = stepContext; throw error; });
|
||||
};
|
||||
const renewal = setInterval(() => {
|
||||
void runStep("renew", () => store.renew(agent, owner, new Date())).catch(() => {});
|
||||
void attempt({ ...context, stage: "renew" }, () => store.renew(agent, owner, new Date())).catch(() => {});
|
||||
}, 30_000);
|
||||
renewal.unref?.();
|
||||
try {
|
||||
@@ -65,8 +69,9 @@ export function createLifecycleWorker(store: LifecycleStore, driver: LifecycleDr
|
||||
async () => { await driver.onReady?.(changed); }).catch(() => {});
|
||||
}
|
||||
if (changed && !isLifecycleWorkPending(changed.lifecycleState)) return;
|
||||
} catch {
|
||||
if (!await runStep("defer", () => store.defer(agent, owner, "The lifecycle step failed. Retry the operation.", new Date()))) continue;
|
||||
} catch (error) {
|
||||
const message = lifecycleFailureMessage(failureContext, error);
|
||||
if (!await runStep("defer", () => store.defer(agent, owner, message, new Date()))) continue;
|
||||
return;
|
||||
} finally {
|
||||
clearInterval(renewal);
|
||||
|
||||
@@ -142,3 +142,5 @@ export function startAgentLifecycle(db: Db, effects: LifecycleEffects, driver: L
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export { lifecycleFailureCode } from "./application/failure.js";
|
||||
Reference in new issue
Block a user