diff --git a/packages/shared/src/index.ts b/packages/shared/src/index.ts index eba88a35d52..c592868f336 100644 --- a/packages/shared/src/index.ts +++ b/packages/shared/src/index.ts @@ -822,6 +822,9 @@ export type { ManagedSettingMetadata, IssueGraphLivenessAutoRecoveryPreview, IssueGraphLivenessAutoRecoveryPreviewItem, + WorktreeRunExecutionSuppressedReason, + WorktreeRunExecutionActivationState, + WorktreeRunEngineStatus, BackupRetentionPolicy, Agent, AgentAccessState, @@ -1611,6 +1614,7 @@ export { MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, PAPERCLIP_CLOUD_MANAGED_BY, + WORKTREE_SEED_QUARANTINE_ERROR_CODE, } from "./types/instance.js"; export type { diff --git a/packages/shared/src/types/index.ts b/packages/shared/src/types/index.ts index 0cfac72d34d..21c3d17d607 100644 --- a/packages/shared/src/types/index.ts +++ b/packages/shared/src/types/index.ts @@ -100,7 +100,11 @@ export type { BackupRetentionPolicy, IssueGraphLivenessAutoRecoveryPreview, IssueGraphLivenessAutoRecoveryPreviewItem, + WorktreeRunExecutionSuppressedReason, + WorktreeRunExecutionActivationState, + WorktreeRunEngineStatus, } from "./instance.js"; +export { WORKTREE_SEED_QUARANTINE_ERROR_CODE } from "./instance.js"; export type { SmokeLabServiceStatus, SmokeRun, diff --git a/packages/shared/src/types/instance.ts b/packages/shared/src/types/instance.ts index a118699915c..f5af931c5d9 100644 --- a/packages/shared/src/types/instance.ts +++ b/packages/shared/src/types/instance.ts @@ -151,6 +151,62 @@ export interface InstanceExperimentalSettingsWithManaged extends InstanceExperim managedKeys?: Partial>; } +/** + * Error code stamped on heartbeat runs that were copied into a worktree seed and + * quarantined (cancelled, inert) so they never execute in the preview instance. + * Owned by the CLI worktree seed flow (cli/src/commands/worktree.ts) and surfaced + * in the UI as "inherited — inactive" runs. + */ +export const WORKTREE_SEED_QUARANTINE_ERROR_CODE = "worktree_seed_quarantine"; + +/** + * Why the worktree run engine is suppressed. Mirrors the server's fail-closed + * ladder in `resolveWorktreeRunExecutionActivation`. + */ +export type WorktreeRunExecutionSuppressedReason = + | "not_worktree_runtime" + | "flag_disabled" + | "missing_cutoff" + | "missing_instance_id" + | "missing_seed_epoch" + | "instance_id_mismatch" + | "settings_read_error"; + +/** + * Resolved worktree run-engine activation. `armed` means the scheduler executes + * runs created after `cutoff`; otherwise `reason` explains the suppression. + */ +export type WorktreeRunExecutionActivationState = + | { + armed: true; + cutoff: string; + activationInstanceId: string; + instanceNonce: string; + seedEpoch: string; + reason: null; + } + | { + armed: false; + cutoff: null; + activationInstanceId: string | null; + reason: WorktreeRunExecutionSuppressedReason; + }; + +/** + * Authoritative boot-truth for the worktree run engine, served to the UI so a + * preview user can tell "inherited, inert" state from live execution at a glance. + */ +export interface WorktreeRunEngineStatus { + /** True when the server runtime is a worktree preview (`PAPERCLIP_IN_WORKTREE`). */ + inWorktree: boolean; + /** Activation resolved server-side (mirrors the scheduler gate). */ + activation: WorktreeRunExecutionActivationState; + /** Current boot's server-managed instance identity (nonce), if stamped. */ + instanceNonce: string | null; + /** Count of inherited heartbeat runs quarantined at seed time (durable evidence). */ + quarantinedRunCount: number; +} + export interface InstanceSettings { id: string; defaultEnvironmentId: string | null; diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index 181ced8bf8c..ec3e8849c2b 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -242,6 +242,14 @@ export function instanceSettingsRoutes(db: Db) { }, ); + router.get("/instance/settings/experimental/worktree-run-engine", async (req, res) => { + // Boot-truth for the worktree run engine (PAP-14312). Readable by any + // authenticated org member or instance admin so the persistent banner and + // inherited-run surfaces render for everyone viewing a preview instance. + assertBoardOrgAccess(req); + res.json(await svc.getWorktreeRunEngineStatus()); + }); + router.post( "/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview", validate(issueGraphLivenessAutoRecoveryRequestSchema), diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index 4538ae6726c..b13d934c533 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -1,6 +1,6 @@ import { randomUUID } from "node:crypto"; import type { Db } from "@paperclipai/db"; -import { companies, instanceSettings } from "@paperclipai/db"; +import { companies, heartbeatRuns, instanceSettings } from "@paperclipai/db"; /** * A `Db` or an open transaction handle — the subset of query builders the @@ -17,6 +17,7 @@ import { DEFAULT_BACKUP_RETENTION, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, PAPERCLIP_CLOUD_MANAGED_BY, + WORKTREE_SEED_QUARANTINE_ERROR_CODE, instanceGeneralSettingsSchema, type InstanceGeneralSettings, instanceExperimentalSettingsSchema, @@ -28,6 +29,9 @@ import { type InstanceSettings, type PatchInstanceSettings, type PatchInstanceExperimentalSettings, + type WorktreeRunExecutionSuppressedReason, + type WorktreeRunExecutionActivationState, + type WorktreeRunEngineStatus, } from "@paperclipai/shared"; import { and, eq, sql } from "drizzle-orm"; import { getManagedInstanceConfig, type ManagedInstanceConfig } from "./managed-config.js"; @@ -44,30 +48,10 @@ interface InstanceSettingsServiceOptions { generateSeedEpoch?: () => string; } -type WorktreeRunExecutionSuppressedReason = - | "not_worktree_runtime" - | "flag_disabled" - | "missing_cutoff" - | "missing_instance_id" - | "missing_seed_epoch" - | "instance_id_mismatch" - | "settings_read_error"; - -export type WorktreeRunExecutionActivationState = - | { - armed: true; - cutoff: string; - activationInstanceId: string; - instanceNonce: string; - seedEpoch: string; - reason: null; - } - | { - armed: false; - cutoff: null; - activationInstanceId: string | null; - reason: WorktreeRunExecutionSuppressedReason; - }; +export type { + WorktreeRunExecutionSuppressedReason, + WorktreeRunExecutionActivationState, +} from "@paperclipai/shared"; export function isTruthyRuntimeEnvValue(value: string | undefined) { return typeof value === "string" && TRUTHY_RUNTIME_ENV_VALUES.has(value.trim().toLowerCase()); @@ -509,6 +493,31 @@ export function instanceSettingsService(db: Db, options: InstanceSettingsService return toInstanceSettings(updated ?? current); }, + getWorktreeRunEngineStatus: async (): Promise => { + const experimental = normalizeExperimentalSettings((await getOrCreateRow()).experimental); + const inWorktree = isTruthyRuntimeEnvValue(runtimeEnv.PAPERCLIP_IN_WORKTREE); + const activation = await resolveWorktreeRunExecutionActivationState({ + getExperimental: async () => experimental, + runtimeEnv, + }); + // Only quarantined-run rows survive a seed (P1 deletes the wakeups/monitors + // it clears), so this count is the durable evidence of what was neutralized. + let quarantinedRunCount = 0; + if (inWorktree) { + const [row] = await db + .select({ count: sql`count(*)::int` }) + .from(heartbeatRuns) + .where(eq(heartbeatRuns.errorCode, WORKTREE_SEED_QUARANTINE_ERROR_CODE)); + quarantinedRunCount = row?.count ?? 0; + } + return { + inWorktree, + activation, + instanceNonce: experimental.worktreeRunExecutionInstanceNonce, + quarantinedRunCount, + }; + }, + listCompanyIds: async (): Promise => db .select({ id: companies.id }) diff --git a/ui/src/api/instanceSettings.ts b/ui/src/api/instanceSettings.ts index 8bf1454b0a2..e5fb6bd4cdc 100644 --- a/ui/src/api/instanceSettings.ts +++ b/ui/src/api/instanceSettings.ts @@ -6,6 +6,7 @@ import type { PatchInstanceSettings, PatchInstanceGeneralSettings, PatchInstanceExperimentalSettings, + WorktreeRunEngineStatus, } from "@paperclipai/shared"; import { api } from "./client"; @@ -22,6 +23,8 @@ export const instanceSettingsApi = { api.get("/instance/settings/experimental"), updateExperimental: (patch: PatchInstanceExperimentalSettings) => api.patch("/instance/settings/experimental", patch), + getWorktreeRunEngine: () => + api.get("/instance/settings/experimental/worktree-run-engine"), previewIssueGraphLivenessAutoRecovery: (input: { lookbackHours?: number }) => api.post( "/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview", diff --git a/ui/src/components/IssueRunLedger.test.tsx b/ui/src/components/IssueRunLedger.test.tsx index 586e462a978..5b6dba5a9b4 100644 --- a/ui/src/components/IssueRunLedger.test.tsx +++ b/ui/src/components/IssueRunLedger.test.tsx @@ -608,4 +608,21 @@ describe("IssueRunLedger", () => { expect(container.querySelector('[data-testid="responsible-user-denial-notice"]')).toBeNull(); }); + + it("marks worktree-seed quarantined runs as inherited and inactive", () => { + renderLedger({ + runs: [ + createRun({ + runId: "run-inherited-1", + status: "cancelled", + livenessState: null, + errorCode: "worktree_seed_quarantine", + }), + ], + }); + + expect(container.textContent).toContain("Inherited · inactive"); + // The inherited badge replaces the noisy liveness classification. + expect(container.textContent).not.toContain("No liveness data"); + }); }); diff --git a/ui/src/components/IssueRunLedger.tsx b/ui/src/components/IssueRunLedger.tsx index a69693b4b72..44470b462d1 100644 --- a/ui/src/components/IssueRunLedger.tsx +++ b/ui/src/components/IssueRunLedger.tsx @@ -1,6 +1,10 @@ import { useMemo, useState, type ReactNode } from "react"; import type { ActivityEvent, Issue, Agent } from "@paperclipai/shared"; -import { isResponsibleUserDenialCode, responsibleUserLabel } from "@paperclipai/shared"; +import { + isResponsibleUserDenialCode, + responsibleUserLabel, + WORKTREE_SEED_QUARANTINE_ERROR_CODE, +} from "@paperclipai/shared"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { Link } from "@/lib/router"; import { accessApi, type CurrentBoardAccess } from "../api/access"; @@ -292,6 +296,15 @@ function isActiveRun(run: Pick) { return run.isLive || ACTIVE_RUN_STATUSES.has(run.status); } +/** + * PAP-14312: runs copied into a worktree seed are quarantined (cancelled + + * `worktree_seed_quarantine`) so they never execute in the preview. Render them + * as "inherited — inactive" instead of a scary failed/cancelled run. + */ +function isInheritedInactiveRun(run: Pick) { + return run.errorCode === WORKTREE_SEED_QUARANTINE_ERROR_CODE; +} + function runSummary(run: LedgerRun, agentMap: ReadonlyMap>) { const agentName = compactAgentName(run, agentMap); if (run.status === "running") return `Running now by ${agentName}`; @@ -696,6 +709,7 @@ export function IssueRunLedgerContent({ return
{renderActivityEvent?.(item.event)}
; } const run = item.run; + const inheritedInactive = isInheritedInactiveRun(run); const liveness = livenessCopyForRun(run); const stopReason = stopReasonLabel(run); const duration = formatDuration(run.startedAt, run.finishedAt); @@ -734,21 +748,31 @@ export function IssueRunLedgerContent({ {statusLabel(run.status)} + {inheritedInactive ? ( + + Inherited · inactive + + ) : null} {run.isLive ? ( live ) : null} - - {liveness.label} - + {inheritedInactive ? null : ( + + {liveness.label} + + )} {exhausted ? ( Exhausted diff --git a/ui/src/components/Layout.tsx b/ui/src/components/Layout.tsx index 205ff57c4af..c3e9d6d85c5 100644 --- a/ui/src/components/Layout.tsx +++ b/ui/src/components/Layout.tsx @@ -17,6 +17,7 @@ import { KeyboardShortcutsCheatsheet } from "./KeyboardShortcutsCheatsheet"; import { ToastViewport } from "./ToastViewport"; import { MobileBottomNav } from "./MobileBottomNav"; import { WorktreeBanner } from "./WorktreeBanner"; +import { WorktreeRunEngineBanner } from "./WorktreeRunEngineBanner"; import { DevRestartBanner } from "./DevRestartBanner"; import { StandaloneBrowserControls } from "./StandaloneBrowserControls"; import { RouteErrorBoundary } from "./RouteErrorBoundary"; @@ -592,6 +593,7 @@ export function Layout() { Skip to Main Content +
{isMobile && sidebarOpen && ( diff --git a/ui/src/components/WorktreeRunEngineBanner.test.tsx b/ui/src/components/WorktreeRunEngineBanner.test.tsx new file mode 100644 index 00000000000..95ffc674c88 --- /dev/null +++ b/ui/src/components/WorktreeRunEngineBanner.test.tsx @@ -0,0 +1,154 @@ +// @vitest-environment jsdom + +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import type { WorktreeRunEngineStatus } from "@paperclipai/shared"; +import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { WorktreeRunEngineBanner } from "./WorktreeRunEngineBanner"; + +function render(status: WorktreeRunEngineStatus | undefined, variant: "strip" | "detail" = "detail") { + const container = document.createElement("div"); + document.body.appendChild(container); + const root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root.render( + + + , + ); + }); + return { container, root }; +} + +function armed(overrides: Partial = {}): WorktreeRunEngineStatus { + return { + inWorktree: true, + activation: { + armed: true, + cutoff: "2026-07-10T18:34:00.000Z", + activationInstanceId: "nonce-aaaaaaaa1111", + reason: null, + }, + instanceNonce: "nonce-aaaaaaaa1111", + quarantinedRunCount: 0, + ...overrides, + }; +} + +function suppressed( + reason: Extract["reason"], + overrides: Partial = {}, +): WorktreeRunEngineStatus { + return { + inWorktree: true, + activation: { + armed: false, + cutoff: null, + activationInstanceId: reason === "instance_id_mismatch" ? "nonce-oldoldold9999" : null, + reason, + }, + instanceNonce: "nonce-current0000", + quarantinedRunCount: 0, + ...overrides, + }; +} + +describe("WorktreeRunEngineBanner", () => { + let roots: Root[] = []; + + beforeEach(() => { + roots = []; + }); + + afterEach(() => { + flushSync(() => { + roots.forEach((root) => root.unmount()); + }); + document.body.innerHTML = ""; + }); + + function mount(status: WorktreeRunEngineStatus | undefined, variant: "strip" | "detail" = "detail") { + const { container, root } = render(status, variant); + roots.push(root); + return container; + } + + it("renders nothing outside a worktree runtime", () => { + const container = mount({ + inWorktree: false, + activation: { armed: false, cutoff: null, activationInstanceId: null, reason: "not_worktree_runtime" }, + instanceNonce: null, + quarantinedRunCount: 0, + }); + expect(container.textContent).toBe(""); + }); + + it("renders nothing while the query has no data", () => { + const container = mount(undefined); + expect(container.textContent).toBe(""); + }); + + it("shows the armed cutoff and a matching identity when armed", () => { + const container = mount(armed({ quarantinedRunCount: 3 })); + expect(container.textContent).toContain("Run engine armed since"); + expect(container.textContent).toContain("matches"); + expect(container.textContent).not.toContain("mismatch"); + expect(container.textContent).toContain("3 inherited runs were quarantined"); + }); + + it("reads an identity mismatch as bound to another instance", () => { + const container = mount(suppressed("instance_id_mismatch")); + expect(container.textContent).toContain("bound to another instance"); + expect(container.textContent).toContain("mismatch"); + // Both identities are surfaced so the mismatch is legible. + expect(container.textContent).toContain("nonce-ol"); + expect(container.textContent).toContain("nonce-cu"); + }); + + it("explains a missing activation cutoff", () => { + const container = mount(suppressed("missing_cutoff")); + expect(container.textContent).toContain("missing activation cutoff"); + expect(container.textContent).toContain("Toggle it off and back on"); + }); + + it("explains a flag-disabled run engine", () => { + const container = mount(suppressed("flag_disabled")); + expect(container.textContent).toContain("Run engine off"); + }); + + it("renders a dense single-line strip variant", () => { + const container = mount(armed(), "strip"); + const strip = container.querySelector('[data-testid="worktree-run-engine-strip"]'); + expect(strip).not.toBeNull(); + expect(strip?.textContent).toContain("Run engine"); + expect(strip?.textContent).toContain("armed since"); + }); + + it("surfaces quarantined counts in the strip when suppressed", () => { + const container = mount(suppressed("flag_disabled", { quarantinedRunCount: 5 }), "strip"); + const strip = container.querySelector('[data-testid="worktree-run-engine-strip"]'); + expect(strip?.textContent).toContain("5 inherited runs inactive"); + }); + + // PAP-14415: the strip supplies its own bold "Run engine" subject, so the + // suppressed predicate must not restate it (no "Run engine run engine off"). + it("does not stutter the run-engine subject in the suppressed strip", () => { + const container = mount(suppressed("flag_disabled", { quarantinedRunCount: 4 }), "strip"); + const text = container + .querySelector('[data-testid="worktree-run-engine-strip"]') + ?.textContent?.replace(/\s+/g, " "); + expect(text).toContain("Run engine off"); + expect(text).not.toMatch(/run engine run engine/i); + }); + + it("uses a bare predicate for a mismatched-identity strip", () => { + const container = mount(suppressed("instance_id_mismatch"), "strip"); + const text = container + .querySelector('[data-testid="worktree-run-engine-strip"]') + ?.textContent?.replace(/\s+/g, " "); + expect(text).toContain("Run engine inactive — bound to another instance"); + expect(text).not.toMatch(/run engine toggle inactive/i); + }); +}); diff --git a/ui/src/components/WorktreeRunEngineBanner.tsx b/ui/src/components/WorktreeRunEngineBanner.tsx new file mode 100644 index 00000000000..dfa06d2213d --- /dev/null +++ b/ui/src/components/WorktreeRunEngineBanner.tsx @@ -0,0 +1,237 @@ +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle, Fingerprint, Pause, Play } from "lucide-react"; +import type { + WorktreeRunEngineStatus, + WorktreeRunExecutionActivationState, +} from "@paperclipai/shared"; +import { instanceSettingsApi } from "@/api/instanceSettings"; +import { queryKeys } from "../lib/queryKeys"; +import { cn } from "../lib/utils"; + +function formatActivationTimestamp(iso: string): string { + const parsed = new Date(iso); + if (Number.isNaN(parsed.getTime())) return iso; + return parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); +} + +function shortIdentity(value: string | null | undefined): string { + if (!value) return "—"; + return value.length > 8 ? value.slice(0, 8) : value; +} + +/** + * `headline`/`detail` drive the fuller detail banner. `stripLabel` is the bare + * predicate the dense strip appends after its own bold "Run engine" subject — + * the detail headlines each restate that subject, so reusing them there stutters + * ("Run engine run engine off"). Keeping a dedicated field stops the two copies + * from drifting. + */ +type SuppressionCopy = { headline: string; detail: string; stripLabel: string }; + +/** + * Human-readable copy for each fail-closed reason. `instance_id_mismatch` is + * handled separately because it interpolates the two identities. + */ +function suppressionCopy( + activation: Extract, + instanceNonce: string | null, +): SuppressionCopy { + switch (activation.reason) { + case "flag_disabled": + return { + headline: "Run engine off", + detail: + "The scheduler will not execute tasks in this worktree. Inherited tasks stay parked. Turn on “Run tasks in this worktree” to arm execution.", + stripLabel: "off", + }; + case "missing_cutoff": + return { + headline: "Execution suppressed — missing activation cutoff", + detail: + "This setting has no activation cutoff, so no tasks run automatically. Toggle it off and back on to arm execution for tasks created here.", + stripLabel: "suppressed — missing cutoff", + }; + case "missing_instance_id": + return { + headline: "Execution suppressed — no instance identity", + detail: + "This worktree has not stamped an instance identity yet, so execution fails closed. Toggle off and back on to arm execution.", + stripLabel: "suppressed — no identity", + }; + case "instance_id_mismatch": + return { + headline: "Toggle inactive — bound to another instance", + detail: `This setting was armed against instance ${shortIdentity( + activation.activationInstanceId, + )} and copied here; this boot is ${shortIdentity( + instanceNonce, + )}. No tasks run automatically. Toggle off and back on to re-arm execution for this instance.`, + stripLabel: "inactive — bound to another instance", + }; + case "settings_read_error": + return { + headline: "Execution suppressed — settings unavailable", + detail: + "The run engine could not read its settings, so it fails closed and no tasks run automatically.", + stripLabel: "suppressed — settings unavailable", + }; + case "not_worktree_runtime": + default: + return { + headline: "Execution suppressed", + detail: "No tasks run automatically in this instance.", + stripLabel: "suppressed", + }; + } +} + +function QuarantineLine({ count }: { count: number }) { + if (count <= 0) return null; + return ( +

+ {count} inherited {count === 1 ? "run was" : "runs were"} quarantined at seed time and remain + inactive. Cleared wakeups and monitors are not retained. +

+ ); +} + +function IdentityLine({ + activation, + instanceNonce, +}: { + activation: WorktreeRunExecutionActivationState; + instanceNonce: string | null; +}) { + const matches = + activation.armed || activation.activationInstanceId === instanceNonce; + return ( +

+

+ ); +} + +/** + * PAP-14312: persistent boot-truth for the worktree run engine. Renders nothing + * outside a worktree runtime. Otherwise it reports whether the scheduler is armed + * or suppressed (and why), which instance identity the toggle is bound to, and how + * many inherited runs were quarantined at seed time. + * + * `variant="strip"` is a single dense line for the app shell; `variant="detail"` + * is the fuller block used on the Experimental Settings page. + */ +export function WorktreeRunEngineBanner({ + variant = "detail", + status: statusOverride, +}: { + variant?: "strip" | "detail"; + /** Injected in tests/stories to bypass the network query. */ + status?: WorktreeRunEngineStatus; +}) { + const query = useQuery({ + queryKey: queryKeys.instance.worktreeRunEngine, + queryFn: () => instanceSettingsApi.getWorktreeRunEngine(), + enabled: statusOverride === undefined, + }); + + const status = statusOverride ?? query.data; + if (!status || !status.inWorktree) return null; + + const { activation, instanceNonce, quarantinedRunCount } = status; + const armed = activation.armed; + + if (variant === "strip") { + return ( +
+ {armed ? ( +
+ ); + } + + if (armed) { + return ( +
+
+
+ + +
+ ); + } + + const copy = suppressionCopy(activation, instanceNonce); + // The identity binding is only meaningful when it's the reason execution is + // suppressed; showing a "mismatch" badge on a plainly-off engine reads as noise. + const showIdentity = activation.reason === "instance_id_mismatch"; + return ( +
+
+
+ {showIdentity ? : null} + +
+ ); +} diff --git a/ui/src/lib/queryKeys.ts b/ui/src/lib/queryKeys.ts index b4680ed1899..9c6204b325a 100644 --- a/ui/src/lib/queryKeys.ts +++ b/ui/src/lib/queryKeys.ts @@ -388,6 +388,7 @@ export const queryKeys = { generalSettings: ["instance", "general-settings"] as const, schedulerHeartbeats: ["instance", "scheduler-heartbeats"] as const, experimentalSettings: ["instance", "experimental-settings"] as const, + worktreeRunEngine: ["instance", "worktree-run-engine"] as const, }, health: ["health"] as const, cloud: { diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index 02e7cb6b01d..73021cbefcb 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -15,6 +15,7 @@ import { queryKeys } from "../lib/queryKeys"; const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), updateExperimental: vi.fn(), + getWorktreeRunEngine: vi.fn(), previewIssueGraphLivenessAutoRecovery: vi.fn(), runIssueGraphLivenessAutoRecovery: vi.fn(), })); @@ -183,6 +184,20 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) currentExperimentalSettings = { ...currentExperimentalSettings, ...patch }; return { ...currentExperimentalSettings }; }); + // Boot-truth banner (PAP-14312) has its own coverage in + // WorktreeRunEngineBanner.test.tsx; here it defaults to not-in-worktree so + // the page tests focus on the toggle and delegation. + mockInstanceSettingsApi.getWorktreeRunEngine.mockResolvedValue({ + inWorktree: false, + activation: { + armed: false, + cutoff: null, + activationInstanceId: null, + reason: "not_worktree_runtime", + }, + instanceNonce: null, + quarantinedRunCount: 0, + }); }); afterEach(() => { @@ -429,59 +444,30 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) "Only tasks created after enabling will run automatically", ); expect(container.textContent).toContain("Toggling off and on resets the cutoff."); - // Off => no armed banner and no fail-closed hint. - expect(container.textContent).not.toContain("Running tasks created after"); - expect(container.textContent).not.toContain("Execution is suppressed"); }); - it("shows the armed timestamp when the flag matches the current instance", async () => { + // The armed/suppressed/identity boot-truth states now live in the + // WorktreeRunEngineBanner (PAP-14312); the settings card only delegates to it. + // Full state coverage lives in WorktreeRunEngineBanner.test.tsx. + it("renders the run-engine boot-truth banner inside the worktree card", async () => { setWorktreeRuntimeMeta(true); - setWorktreeInstanceIdMeta("inst-current"); - currentExperimentalSettings = { - ...currentExperimentalSettings, - enableWorktreeRunExecution: true, - worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z", - worktreeRunExecutionActivationInstanceId: "inst-current", - }; + mockInstanceSettingsApi.getWorktreeRunEngine.mockResolvedValue({ + inWorktree: true, + activation: { + armed: true, + cutoff: "2026-07-10T18:34:00.000Z", + activationInstanceId: "nonce-current", + reason: null, + }, + instanceNonce: "nonce-current", + quarantinedRunCount: 2, + }); await renderPage(); - expect(container.textContent).toContain("Running tasks created after"); - expect(container.textContent).not.toContain("Execution is suppressed"); - const toggle = container.querySelector(WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR); - expect(toggle?.getAttribute("aria-checked")).toBe("true"); - }); - - it("fails closed with a re-enable hint when the flag was armed in another instance", async () => { - setWorktreeRuntimeMeta(true); - setWorktreeInstanceIdMeta("inst-current"); - currentExperimentalSettings = { - ...currentExperimentalSettings, - enableWorktreeRunExecution: true, - worktreeRunExecutionActivatedAt: "2026-07-10T18:34:00.000Z", - worktreeRunExecutionActivationInstanceId: "inst-other", - }; - await renderPage(); - - expect(container.textContent).toContain("Execution is suppressed"); - expect(container.textContent).toContain("armed in a different instance"); - expect(container.textContent).toContain("Toggle it off and back on"); - expect(container.textContent).not.toContain("Running tasks created after"); - }); - - it("fails closed with a re-enable hint when the activation cutoff is missing", async () => { - setWorktreeRuntimeMeta(true); - setWorktreeInstanceIdMeta("inst-current"); - currentExperimentalSettings = { - ...currentExperimentalSettings, - enableWorktreeRunExecution: true, - worktreeRunExecutionActivatedAt: null, - worktreeRunExecutionActivationInstanceId: null, - }; - await renderPage(); - - expect(container.textContent).toContain("Execution is suppressed"); - expect(container.textContent).toContain("missing its activation cutoff"); - expect(container.textContent).not.toContain("Running tasks created after"); + const banner = container.querySelector('[data-testid="worktree-run-engine-banner"]'); + expect(banner).not.toBeNull(); + expect(banner?.textContent).toContain("Run engine armed since"); + expect(banner?.textContent).toContain("2 inherited runs were quarantined"); }); it("renders and patches the Built-in Agents experimental toggle", async () => { diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index 284fed071b9..d8862f95fd3 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -13,6 +13,7 @@ import { experimentalSettingKey } from "@paperclipai/shared"; import { instanceSettingsApi } from "@/api/instanceSettings"; import { useHiddenSettings } from "@/hooks/useHiddenSettings"; import { getWorktreeInstanceId, isWorktreeRuntime } from "../lib/worktree-branding"; +import { WorktreeRunEngineBanner } from "@/components/WorktreeRunEngineBanner"; import { useBreadcrumbs } from "../context/BreadcrumbContext"; import { queryKeys } from "../lib/queryKeys"; import { ToggleSwitch } from "@/components/ui/toggle-switch"; @@ -39,43 +40,6 @@ function formatRecoveryState(state: string) { return state.replace(/_/g, " "); } -type WorktreeRunExecutionDisplayState = - | { kind: "off" } - | { kind: "armed"; activatedAt: string } - | { kind: "fail_closed"; reason: "missing_cutoff" | "missing_instance_id" | "instance_mismatch" }; - -/** - * Mirror of the server's `resolveWorktreeRunExecutionActivation` fail-closed - * ladder (server/src/services/instance-settings.ts) so the card never claims a - * copied/legacy row is arming execution. The derived fields are display-only — - * the PATCH the toggle sends still writes just the boolean. - */ -function resolveWorktreeRunExecutionDisplayState( - settings: - | Pick< - InstanceExperimentalSettings, - | "enableWorktreeRunExecution" - | "worktreeRunExecutionActivatedAt" - | "worktreeRunExecutionActivationInstanceId" - > - | undefined, - currentInstanceId: string | null, -): WorktreeRunExecutionDisplayState { - if (settings?.enableWorktreeRunExecution !== true) return { kind: "off" }; - if (!settings.worktreeRunExecutionActivatedAt) return { kind: "fail_closed", reason: "missing_cutoff" }; - if (!currentInstanceId) return { kind: "fail_closed", reason: "missing_instance_id" }; - if (settings.worktreeRunExecutionActivationInstanceId !== currentInstanceId) { - return { kind: "fail_closed", reason: "instance_mismatch" }; - } - return { kind: "armed", activatedAt: settings.worktreeRunExecutionActivatedAt }; -} - -function formatActivationTimestamp(iso: string): string { - const parsed = new Date(iso); - if (Number.isNaN(parsed.getTime())) return iso; - return parsed.toLocaleString(undefined, { dateStyle: "medium", timeStyle: "short" }); -} - // PAP-11233: keep Conference Room code intact, but hide the user-facing opt-in for now. const SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING = false; @@ -277,6 +241,7 @@ export function InstanceExperimentalSettings() { queryClient.setQueryData(queryKeys.instance.experimentalSettings, updatedSettings); await Promise.all([ queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }), + queryClient.invalidateQueries({ queryKey: queryKeys.instance.worktreeRunEngine }), queryClient.invalidateQueries({ queryKey: ["built-in-agents"] }), queryClient.invalidateQueries({ queryKey: queryKeys.health }), ]); @@ -467,6 +432,37 @@ export function InstanceExperimentalSettings() {
)} + {inWorktree ? ( + +
+
+
+
+

Run tasks in this worktree

+ {worktreeRunExecutionManaged ? : null} +
+

+ This is an isolated git-worktree preview instance. Turn this on to let the scheduler execute runs + here. Only tasks created after enabling will run automatically — copied/pre-existing tasks stay + parked. Toggling off and on resets the cutoff. +

+
+ { + if (worktreeRunExecutionManaged) return; + toggleMutation.mutate({ enableWorktreeRunExecution: checked }); + }} + disabled={toggleMutation.isPending || worktreeRunExecutionManaged} + aria-label="Toggle worktree run execution setting" + /> +
+ + +
+
+ ) : null} +