From 141f202e4099d2be58d5dd3e9101b076f4a39aa0 Mon Sep 17 00:00:00 2001 From: Dotta <34892728+cryppadotta@users.noreply.github.com> Date: Tue, 1 Sep 2026 14:23:05 -0500 Subject: [PATCH] Clean up experimental settings features (#12681) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Thinking Path > - Paperclip is the open source app that people use to manage AI agents for work. > - Instance settings control optional product features and developer tools. > - The experimental settings page mixed active experiments, internal tools, and old recovery controls. > - Some workspace links also used the selected company instead of the workspace owner. > - These problems made settings hard to scan and could send users to the wrong company route. > - This pull request removes old controls, groups developer settings, and resolves workspace links from workspace data. > - The benefit is a smaller settings surface and correct workspace navigation. ## Linked Issues or Issue Description **What existing behavior does this improve?** This improves the instance experimental settings page, task watchdog controls, dependency wake recovery, and execution workspace routes. **Current behavior** The settings page shows old recovery controls and mixes product experiments with internal developer settings. Task watchdogs require an extra feature flag. Some direct workspace links use the current company prefix instead of the company that owns the workspace. **Proposed behavior** Remove the old task recovery experiment and its unused API surface. Make task watchdog controls available without the removed flag. Put worktree execution and managed environment controls in the developer section. Resolve direct workspace links from the workspace owner and reject a company prefix that does not own the workspace. **Reason and benefit** The smaller settings page is easier to understand. The server keeps only the dependency wake backstop that it still uses. Workspace links open under the correct company route. **Breaking changes** This removes the experimental issue graph recovery preview and run endpoints. It also removes the task watchdog feature flag. Task watchdog data and dependency wake behavior remain available. ## What Changed - Removed the old task watchdog and issue graph recovery feature flags. - Removed the old issue graph recovery preview, run controls, API contracts, and unused recovery implementation. - Kept resolved dependency wakes as the scheduler backstop. - Grouped product experiments and Paperclip developer settings on the instance settings page. - Made task watchdog controls available without an extra experimental flag. - Added owner-aware redirects and company checks for execution workspace routes. - Hid the false stopped-state badge while a workspace has no active runtime state. - Updated focused server and UI tests for the new behavior. ## Verification - `pnpm check:token-gates` - `pnpm -r typecheck` - `pnpm build` - `pnpm test:run` completed with 5,620 passing tests and four failures in unchanged workspace runtime port tests. The same four failures repeat when the two files run alone. - The complete GitHub CI matrix passed, including all server, serialized server, build, canary, and end-to-end jobs. ## Risks - Clients that call the removed experimental recovery endpoints must stop calling them. - The route checks depend on workspace detail access. An unknown or cross-company workspace returns the global not-found page. - There are no database migrations, lockfile changes, workflow changes, or design image changes. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used OpenAI Codex with GPT-5. The exact deployment ID and context window are not exposed. Reasoning, tool use, and code execution were enabled. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip --- doc/execution-semantics.md | 2 - packages/shared/src/feature-catalog.ts | 18 +- packages/shared/src/index.ts | 7 - packages/shared/src/types/index.ts | 5 - packages/shared/src/types/instance.ts | 38 - packages/shared/src/validators/index.ts | 2 - .../shared/src/validators/instance.test.ts | 10 + packages/shared/src/validators/instance.ts | 23 - ...eartbeat-issue-liveness-escalation.test.ts | 805 +------------ .../instance-settings-routes.test.ts | 138 +-- .../instance-settings-service.test.ts | 16 +- server/src/__tests__/managed-config.test.ts | 1 - .../server-startup-feedback-export.test.ts | 5 +- server/src/index.ts | 14 +- server/src/routes/instance-settings.ts | 50 - server/src/routes/openapi.ts | 17 - server/src/services/heartbeat.ts | 23 +- server/src/services/instance-settings.ts | 10 - server/src/services/recovery/service.ts | 1059 ----------------- ui/src/App.activity-routing.test.tsx | 14 + ui/src/App.tsx | 32 +- ui/src/api/instanceSettings.ts | 33 - ui/src/components/IssueProperties.test.tsx | 29 +- ui/src/components/NewIssueDialog.test.tsx | 2 - ui/src/components/NewIssueDialog.tsx | 51 +- ...refixedExecutionWorkspaceRedirect.test.tsx | 115 ++ .../UnprefixedExecutionWorkspaceRedirect.tsx | 64 + .../components/WorkspaceAccessCard.test.tsx | 17 + ui/src/components/WorkspaceAccessCard.tsx | 32 +- .../issue-properties/IssueProperties.tsx | 51 +- ui/src/lib/company-routes.test.ts | 6 + ui/src/lib/company-routes.ts | 1 + ui/src/lib/workspace-access-state.test.ts | 26 +- ui/src/lib/workspace-access-state.ts | 23 +- ui/src/pages/Agents.test.tsx | 3 - .../InstanceExperimentalSettings.test.tsx | 236 +--- ui/src/pages/InstanceExperimentalSettings.tsx | 966 ++++++--------- 37 files changed, 810 insertions(+), 3134 deletions(-) create mode 100644 ui/src/components/UnprefixedExecutionWorkspaceRedirect.test.tsx create mode 100644 ui/src/components/UnprefixedExecutionWorkspaceRedirect.tsx diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 33d918b12a..d47d34ae9d 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -802,8 +802,6 @@ An upgrade may encounter an already-active agent-owned recovery action. Papercli Create an issue-backed recovery action only when a separate issue is the right execution object. In that fallback form, the source issue remains visible and is blocked on the recovery issue when blocking is necessary for correctness. The recovery owner must restore a live path, resolve the source issue manually, delegate real follow-up work, or record the reason the signal is a false positive. -Instance-level issue-graph liveness auto-recovery is disabled by default. When enabled, its lookback window means "dependency paths updated within the last N hours"; older findings remain advisory and are counted as outside the configured lookback instead of creating recovery actions automatically. This is an operator noise control, not the older staleness delay for determining whether a chain is old enough to surface. - ### Human Escalation Human escalation is required when the next safe action depends on board judgment, budget/approval policy, or information unavailable to the control plane. diff --git a/packages/shared/src/feature-catalog.ts b/packages/shared/src/feature-catalog.ts index ca9aa77993..68c0903afd 100644 --- a/packages/shared/src/feature-catalog.ts +++ b/packages/shared/src/feature-catalog.ts @@ -120,16 +120,8 @@ export const INSTANCE_FEATURE_CATALOG: Record; -} - -export interface IssueGraphLivenessAutoRecoveryPreview { - lookbackHours: number; - cutoff: string; - generatedAt: string; - findings: number; - recoverableFindings: number; - skippedOutsideLookback: number; - items: IssueGraphLivenessAutoRecoveryPreviewItem[]; -} diff --git a/packages/shared/src/validators/index.ts b/packages/shared/src/validators/index.ts index 336a60b0e5..9ddf4e6e39 100644 --- a/packages/shared/src/validators/index.ts +++ b/packages/shared/src/validators/index.ts @@ -48,11 +48,9 @@ export { managedSettingMetadataSchema, patchInstanceExperimentalSettingsSchema, patchInstanceSettingsSchema, - issueGraphLivenessAutoRecoveryRequestSchema, type InstanceExperimentalSettings, type PatchInstanceExperimentalSettings, type PatchInstanceSettings, - type IssueGraphLivenessAutoRecoveryRequest, } from "./instance.js"; export { diff --git a/packages/shared/src/validators/instance.test.ts b/packages/shared/src/validators/instance.test.ts index 1cfe42000a..db146f4ae7 100644 --- a/packages/shared/src/validators/instance.test.ts +++ b/packages/shared/src/validators/instance.test.ts @@ -18,6 +18,16 @@ describe("instance experimental settings validators", () => { ).toEqual({ enablePaperclipDeveloperMode: true }); }); + it("strips retired watchdog and liveness auto-recovery settings", () => { + expect( + patchInstanceExperimentalSettingsSchema.parse({ + enableTaskWatchdogs: false, + enableIssueGraphLivenessAutoRecovery: true, + issueGraphLivenessAutoRecoveryLookbackHours: 24, + }), + ).toEqual({}); + }); + it("defaults workspace branch repair settings on", () => { const settings = instanceExperimentalSettingsSchema.parse({}); diff --git a/packages/shared/src/validators/instance.ts b/packages/shared/src/validators/instance.ts index 8f7848f833..2ad7fb5a16 100644 --- a/packages/shared/src/validators/instance.ts +++ b/packages/shared/src/validators/instance.ts @@ -5,9 +5,6 @@ import { WEEKLY_RETENTION_PRESETS, MONTHLY_RETENTION_PRESETS, DEFAULT_BACKUP_RETENTION, - DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, - MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, - MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, } from "../types/instance.js"; import { feedbackDataSharingPreferenceSchema } from "./feedback.js"; import { shapeWithoutDefaults } from "./partial.js"; @@ -53,7 +50,6 @@ export const instanceExperimentalSettingsSchema = z.object({ enableCases: z.boolean().default(false), enableConferenceRoomChat: z.boolean().default(false), enableClassicTaskInterface: z.boolean().default(false), - enableTaskWatchdogs: z.boolean().default(false), enableIssuePlanDecompositions: z.boolean().default(false), enableExperimentalFileViewer: z.boolean().default(false), enableExternalObjects: z.boolean().default(false), @@ -68,7 +64,6 @@ export const instanceExperimentalSettingsSchema = z.object({ enablePaperclipDeveloperMode: z.boolean().default(false), enableSimplifiedEnglishInteractions: z.boolean().default(false), autoRestartDevServerWhenIdle: z.boolean().default(false), - enableIssueGraphLivenessAutoRecovery: z.boolean().default(false), enableWorkspaceBranchReconcileForward: z.boolean().default(true), enableWorkspaceDirtyQuarantineRepair: z.boolean().default(true), enableOwnerInstanceAdmin: z.boolean().default(false), @@ -82,12 +77,6 @@ export const instanceExperimentalSettingsSchema = z.object({ enableWorktreeRunExecution: z.boolean().default(false), worktreeRunExecutionActivatedAt: z.string().datetime().nullable().default(null), worktreeRunExecutionActivationInstanceId: z.string().min(1).nullable().default(null), - issueGraphLivenessAutoRecoveryLookbackHours: z - .number() - .int() - .min(MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS) - .max(MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS) - .default(DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS), }).strict(); export const patchInstanceExperimentalSettingsSchema = z @@ -120,15 +109,6 @@ export const patchInstanceSettingsSchema = z.object({ defaultEnvironmentId: z.string().guid().nullable().optional(), }).strict(); -export const issueGraphLivenessAutoRecoveryRequestSchema = z.object({ - lookbackHours: z - .number() - .int() - .min(MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS) - .max(MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS) - .optional(), -}).strict(); - // The longest time a task drain can run before it expires on its own. A // caller can send a shorter `ttlMs`, but not a longer one — the request must // fail instead of the server silently clamping the value. @@ -150,9 +130,6 @@ export type PatchInstanceExperimentalSettings = Partial< > >; export type PatchInstanceSettings = z.infer; -export type IssueGraphLivenessAutoRecoveryRequest = z.infer< - typeof issueGraphLivenessAutoRecoveryRequestSchema ->; export type StartTaskDrainRequest = z.infer; export const instanceSettingsSchema = z.object({ diff --git a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts index 7619972cd2..6bef458a78 100644 --- a/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts +++ b/server/src/__tests__/heartbeat-issue-liveness-escalation.test.ts @@ -68,10 +68,8 @@ vi.mock("../adapters/index.ts", async () => { import { heartbeatService } from "../services/heartbeat.ts"; import { attentionService } from "../services/attention.ts"; -import { instanceSettingsService } from "../services/instance-settings.ts"; import { issueService } from "../services/issues.ts"; import { runningProcesses } from "../adapters/index.ts"; -import { DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS } from "../services/recovery/service.ts"; import { buildIssueBlockersResolvedWakeStateKey, buildIssueBlockersResolvedWakeStateKeyWithoutCycle, @@ -86,7 +84,7 @@ if (!embeddedPostgresSupport.supported) { ); } -describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { +describeEmbeddedPostgres("heartbeat resolved dependency wake reconciliation", () => { let tempDb: Awaited> | null = null; let db: ReturnType; @@ -98,7 +96,7 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { afterEach(async () => { vi.clearAllMocks(); runningProcesses.clear(); - // reconcileIssueGraphLiveness heals dependency wakes by enqueuing an + // Dependency reconciliation heals missing wakes by enqueuing an // on-demand wake, which dispatches a heartbeat run fire-and-forget (see // startNextQueuedRunForAgent → executeRun in the heartbeat service). That // background run keeps writing rows (workspace_operations, heartbeat_run_events) @@ -127,23 +125,12 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { await db.delete(companyMemberships); await db.delete(companySkills); await db.delete(companies); - await instanceSettingsService(db).updateExperimental({ - enableIssueGraphLivenessAutoRecovery: false, - enableIsolatedWorkspaces: false, - issueGraphLivenessAutoRecoveryLookbackHours: 24, - }); }); afterAll(async () => { await tempDb?.cleanup(); }, 30_000); - async function enableAutoRecovery() { - await instanceSettingsService(db).updateExperimental({ - enableIssueGraphLivenessAutoRecovery: true, - }); - } - async function seedBlockedChain(opts: { outsideLookback?: boolean; blockerStatus?: string; @@ -353,27 +340,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { return { companyId, agentId, blockedIssueId, blockerIssueId, executionWorkspaceId }; } - it("keeps liveness findings advisory when auto recovery is disabled", async () => { - await instanceSettingsService(db).updateExperimental({ - enableIssueGraphLivenessAutoRecovery: false, - }); - const { companyId } = await seedBlockedChain(); - const heartbeat = heartbeatService(db); - - const result = await heartbeat.reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(1); - expect(result.autoRecoveryEnabled).toBe(false); - expect(result.escalationsCreated).toBe(0); - expect(result.skippedAutoRecoveryDisabled).toBe(1); - - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(0); - }); - it("runs exactly one bounded review-path recovery before surfacing a stalled decision", async () => { const companyId = randomUUID(); const agentId = randomUUID(); @@ -465,16 +431,14 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); }); - it("keeps resolved dependency wake reconciliation active when liveness auto recovery is disabled", async () => { + it("keeps resolved dependency wake reconciliation active", async () => { const { companyId, agentId, blockedIssueId, blockerIssueId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.autoRecoveryEnabled).toBe(false); - expect(result.dependencyWakesHealed).toBe(1); - expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]); - expect(result.escalationsCreated).toBe(0); + expect(result.healed).toBe(1); + expect(result.issueIds).toEqual([blockedIssueId]); const wake = await db .select({ @@ -508,16 +472,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); it("heals a blocked dependent whose done blocker has no workspace finalize obligation", async () => { - await enableAutoRecovery(); const { companyId, agentId, blockedIssueId, blockerIssueId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.findings).toBe(0); - expect(result.dependencyWakesHealed).toBe(1); - expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]); - expect(result.escalationsCreated).toBe(0); + expect(result.healed).toBe(1); + expect(result.issueIds).toEqual([blockedIssueId]); const wake = await db .select({ @@ -552,20 +513,20 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { await seedResolvedDependencyBackstopFixture({ workspaceState: "none", assignee: null }); const heartbeat = heartbeatService(db); - const beforeAssignment = await heartbeat.reconcileIssueGraphLiveness(); + const beforeAssignment = await heartbeat.reconcileResolvedDependencyWakes(); - expect(beforeAssignment.dependencyWakesHealed).toBe(0); - expect(beforeAssignment.dependencyWakeBackstopChecked).toBe(0); + expect(beforeAssignment.healed).toBe(0); + expect(beforeAssignment.checked).toBe(0); await db .update(issues) .set({ assigneeAgentId: agentId, updatedAt: new Date() }) .where(eq(issues.id, blockedIssueId)); - const afterAssignment = await heartbeat.reconcileIssueGraphLiveness(); + const afterAssignment = await heartbeat.reconcileResolvedDependencyWakes(); - expect(afterAssignment.dependencyWakesHealed).toBe(1); - expect(afterAssignment.dependencyWakeIssueIds).toEqual([blockedIssueId]); + expect(afterAssignment.healed).toBe(1); + expect(afterAssignment.issueIds).toEqual([blockedIssueId]); const wake = await db .select({ @@ -612,10 +573,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { idempotencyKey, }); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.dependencyWakesHealed).toBe(1); - expect(result.dependencyWakeExistingSkipped).toBe(0); + expect(result.healed).toBe(1); + expect(result.existingWakeSkipped).toBe(0); const wakes = await db .select({ @@ -634,16 +595,14 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); it("waits for workspace finalize before healing a resolved blocked dependent", async () => { - await enableAutoRecovery(); const { companyId, agentId, blockedIssueId, blockerIssueId, executionWorkspaceId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "not_finalized" }); const heartbeat = heartbeatService(db); - const beforeFinalize = await heartbeat.reconcileIssueGraphLiveness(); + const beforeFinalize = await heartbeat.reconcileResolvedDependencyWakes(); - expect(beforeFinalize.findings).toBe(0); - expect(beforeFinalize.dependencyWakesHealed).toBe(0); - expect(beforeFinalize.dependencyWakeNotReadySkipped).toBe(1); + expect(beforeFinalize.healed).toBe(0); + expect(beforeFinalize.notReadySkipped).toBe(1); const wakesBeforeFinalize = await db .select() @@ -660,10 +619,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { startedAt: new Date(), }); - const afterFinalize = await heartbeat.reconcileIssueGraphLiveness(); + const afterFinalize = await heartbeat.reconcileResolvedDependencyWakes(); - expect(afterFinalize.dependencyWakesHealed).toBe(1); - expect(afterFinalize.dependencyWakeIssueIds).toEqual([blockedIssueId]); + expect(afterFinalize.healed).toBe(1); + expect(afterFinalize.issueIds).toEqual([blockedIssueId]); const wake = await db .select({ @@ -684,7 +643,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); it("does not duplicate an existing dependency wake keyed to any resolved blocker", async () => { - await enableAutoRecovery(); const { companyId, agentId, blockedIssueId, blockerIssueId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); const secondBlockerIssueId = randomUUID(); @@ -724,10 +682,10 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { idempotencyKey: `issue_blockers_resolved:${blockedIssueId}:${blockerIdNotUsedByBackstop}`, }); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.dependencyWakesHealed).toBe(0); - expect(result.dependencyWakeExistingSkipped).toBe(1); + expect(result.healed).toBe(0); + expect(result.existingWakeSkipped).toBe(1); const wakes = await db .select({ @@ -743,7 +701,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); it("heals a multi-blocker dependent when only a completed wake for an earlier blocker exists", async () => { - await enableAutoRecovery(); const { companyId, agentId, blockedIssueId, blockerIssueId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); const secondBlockerIssueId = randomUUID(); @@ -785,11 +742,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { const readiness = await issueService(db).getDependencyReadiness(blockedIssueId); expect(readiness.isDependencyReady).toBe(true); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.dependencyWakesHealed).toBe(1); - expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]); - expect(result.dependencyWakeExistingSkipped).toBe(0); + expect(result.healed).toBe(1); + expect(result.issueIds).toEqual([blockedIssueId]); + expect(result.existingWakeSkipped).toBe(0); const stateKey = buildIssueBlockersResolvedWakeStateKey({ dependentIssueId: blockedIssueId, @@ -805,8 +762,8 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { // A second reconciliation pass finds the state-key wake and stays bounded: // it heals nothing more and never enqueues a second wake for the same state. - const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness(); - expect(secondPass.dependencyWakesHealed).toBe(0); + const secondPass = await heartbeatService(db).reconcileResolvedDependencyWakes(); + expect(secondPass.healed).toBe(0); const stateKeyWakes = await db .select({ id: agentWakeupRequests.id }) @@ -816,7 +773,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); it("heals a blocked dependent after a terminal reset when a previous-cycle old-key wake exists", async () => { - await enableAutoRecovery(); const { companyId, agentId, blockedIssueId, blockerIssueId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); const previousCycleWakeAt = new Date("2026-07-01T12:00:00.000Z"); @@ -845,11 +801,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }), }); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.dependencyWakesHealed).toBe(1); - expect(result.dependencyWakeIssueIds).toEqual([blockedIssueId]); - expect(result.dependencyWakeExistingSkipped).toBe(0); + expect(result.healed).toBe(1); + expect(result.issueIds).toEqual([blockedIssueId]); + expect(result.existingWakeSkipped).toBe(0); const cycleKey = buildIssueBlockersResolvedWakeStateKey({ dependentIssueId: blockedIssueId, @@ -864,8 +820,8 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { expect(healedWake).not.toBeNull(); expect(["queued", "claimed", "completed"]).toContain(healedWake?.status); - const secondPass = await heartbeatService(db).reconcileIssueGraphLiveness(); - expect(secondPass.dependencyWakesHealed).toBe(0); + const secondPass = await heartbeatService(db).reconcileResolvedDependencyWakes(); + expect(secondPass.healed).toBe(0); const cycleKeyWakes = await db .select({ id: agentWakeupRequests.id }) @@ -875,7 +831,6 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); it("does not re-heal when a completed old-key wake is from the current blocked cycle", async () => { - await enableAutoRecovery(); const { companyId, agentId, blockedIssueId, blockerIssueId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); const blockedTransitionAt = new Date("2026-08-01T12:00:00.000Z"); @@ -904,14 +859,13 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }), }); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.dependencyWakesHealed).toBe(0); - expect(result.dependencyWakeExistingSkipped).toBe(1); + expect(result.healed).toBe(0); + expect(result.existingWakeSkipped).toBe(1); }); it("counts null dependency wake returns as deferred instead of enqueue failures", async () => { - await enableAutoRecovery(); const { companyId, agentId } = await seedResolvedDependencyBackstopFixture({ workspaceState: "none" }); await db @@ -921,11 +875,11 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }) .where(eq(agents.id, agentId)); - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); + const result = await heartbeatService(db).reconcileResolvedDependencyWakes(); - expect(result.dependencyWakesHealed).toBe(0); - expect(result.dependencyWakeDeferredOrFailed).toBe(1); - expect(result.dependencyWakeEnqueueFailed).toBe(0); + expect(result.healed).toBe(0); + expect(result.deferredOrFailed).toBe(1); + expect(result.enqueueFailed).toBe(0); const skippedWake = await db .select({ @@ -941,671 +895,4 @@ describeEmbeddedPostgres("heartbeat issue graph liveness escalation", () => { }); }); - it("does not create recovery issues outside the configured lookback window", async () => { - await enableAutoRecovery(); - const { companyId } = await seedBlockedChain({ outsideLookback: true }); - const heartbeat = heartbeatService(db); - - const result = await heartbeat.reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(1); - expect(result.escalationsCreated).toBe(0); - expect(result.skippedOutsideLookback).toBe(1); - - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(0); - }); - - it("suppresses liveness escalation when the source issue is under an active pause hold", async () => { - await enableAutoRecovery(); - const { companyId, blockedIssueId } = await seedBlockedChain(); - - await db.insert(issueTreeHolds).values({ - companyId, - rootIssueId: blockedIssueId, - mode: "pause", - status: "active", - reason: "pause liveness recovery subtree", - releasePolicy: { strategy: "manual" }, - }); - - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(1); - expect(result.escalationsCreated).toBe(0); - expect(result.existingEscalations).toBe(0); - expect(result.skipped).toBe(1); - - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(0); - }); - - it("treats an active executionRunId on the leaf blocker as a live execution path", async () => { - await enableAutoRecovery(); - const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const runId = randomUUID(); - await db.insert(heartbeatRuns).values({ - id: runId, - companyId, - agentId: managerId, - status: "running", - contextSnapshot: { issueId: blockedIssueId }, - }); - await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, blockerIssueId)); - const heartbeat = heartbeatService(db); - - const result = await heartbeat.reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(0); - expect(result.escalationsCreated).toBe(0); - }); - - it("creates one bounded escalation for an assigned backlog blocker leaf", async () => { - await enableAutoRecovery(); - const { companyId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain({ - blockerStatus: "backlog", - blockerAssigneeAgentId: "coder", - }); - const heartbeat = heartbeatService(db); - - const first = await heartbeat.reconcileIssueGraphLiveness(); - const second = await heartbeat.reconcileIssueGraphLiveness(); - - expect(first.findings).toBe(1); - expect(first.escalationsCreated).toBe(1); - expect(second.findings).toBe(0); - expect(second.escalationsCreated).toBe(0); - - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(1); - expect(escalations[0]).toMatchObject({ - parentId: blockerIssueId, - assigneeAgentId: coderId, - originId: [ - "harness_liveness", - companyId, - blockedIssueId, - "blocked_by_assigned_backlog_issue", - blockerIssueId, - ].join(":"), - originFingerprint: [ - "harness_liveness_leaf", - companyId, - "blocked_by_assigned_backlog_issue", - blockerIssueId, - ].join(":"), - }); - }); - - it("treats open recovery issues as active waiting paths for non-assigned-backlog states", async () => { - await enableAutoRecovery(); - const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const existingEscalationId = randomUUID(); - - await db.insert(issues).values({ - id: existingEscalationId, - companyId, - title: "Existing liveness unblock work", - status: "todo", - priority: "high", - parentId: blockerIssueId, - assigneeAgentId: managerId, - issueNumber: 5, - identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`, - originKind: "harness_liveness_escalation", - originId: [ - "harness_liveness", - companyId, - blockedIssueId, - "in_review_without_action_path", - blockerIssueId, - ].join(":"), - }); - - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(0); - expect(result.escalationsCreated).toBe(0); - expect(result.existingEscalations).toBe(0); - - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(1); - }); - - it("keeps active invalid_review_participant recoveries from being retired", async () => { - await enableAutoRecovery(); - const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const existingEscalationId = randomUUID(); - - await db.insert(issues).values({ - id: existingEscalationId, - companyId, - title: "Existing invalid review participant unblock work", - status: "todo", - priority: "high", - parentId: blockedIssueId, - assigneeAgentId: managerId, - issueNumber: 5, - identifier: `${`P${companyId.replace(/-/g, "").slice(0, 4)}`}-5`, - originKind: "harness_liveness_escalation", - originId: [ - "harness_liveness", - companyId, - blockedIssueId, - "invalid_review_participant", - blockerIssueId, - ].join(":"), - }); - - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(0); - expect(result.escalationsCreated).toBe(0); - expect(result.existingEscalations).toBe(0); - - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(1); - }); - - it("creates one manager escalation, preserves blockers, and records owner selection", async () => { - await enableAutoRecovery(); - const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const heartbeat = heartbeatService(db); - - const first = await heartbeat.reconcileIssueGraphLiveness(); - - expect(first.escalationsCreated).toBe(1); - const [sourceAfterFirst] = await db - .select({ updatedAt: issues.updatedAt }) - .from(issues) - .where(eq(issues.id, blockedIssueId)); - const eventsAfterFirst = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)); - expect(eventsAfterFirst.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1); - - const second = await heartbeat.reconcileIssueGraphLiveness(); - - expect(second.escalationsCreated).toBe(0); - const [sourceAfterSecond] = await db - .select({ updatedAt: issues.updatedAt }) - .from(issues) - .where(eq(issues.id, blockedIssueId)); - expect(sourceAfterSecond?.updatedAt.getTime()).toBe(sourceAfterFirst?.updatedAt.getTime()); - - const escalations = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "harness_liveness_escalation"), - ), - ); - expect(escalations).toHaveLength(1); - expect(escalations[0]).toMatchObject({ - parentId: blockerIssueId, - assigneeAgentId: managerId, - assigneeAdapterOverrides: { modelProfile: "cheap" }, - status: expect.stringMatching(/^(todo|in_progress|done)$/), - originFingerprint: [ - "harness_liveness_leaf", - companyId, - "blocked_by_unassigned_issue", - blockerIssueId, - ].join(":"), - }); - - const blockers = await db - .select({ blockerIssueId: issueRelations.issueId }) - .from(issueRelations) - .where(eq(issueRelations.relatedIssueId, blockedIssueId)); - expect(blockers.map((row) => row.blockerIssueId).sort()).toEqual( - [blockerIssueId, escalations[0]!.id].sort(), - ); - - const comments = await db.select().from(issueComments).where(eq(issueComments.issueId, blockedIssueId)); - expect(comments).toHaveLength(1); - expect(comments[0]?.body).toContain("harness-level liveness incident"); - expect(comments[0]?.body).toContain(escalations[0]?.identifier ?? escalations[0]!.id); - - const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)); - const createdEvent = events.find((event) => event.action === "issue.harness_liveness_escalation_created"); - expect(createdEvent).toBeTruthy(); - expect(createdEvent?.details).toMatchObject({ - recoveryIssueId: blockerIssueId, - ownerSelection: { - selectedAgentId: managerId, - selectedReason: "root_agent", - selectedSourceIssueId: blockerIssueId, - }, - workspaceSelection: { - reuseRecoveryExecutionWorkspace: false, - inheritedExecutionWorkspaceFromIssueId: null, - projectWorkspaceSourceIssueId: blockerIssueId, - }, - }); - expect(events.filter((event) => event.action === "issue.blockers.updated")).toHaveLength(1); - }); - - it("skips budget-blocked direct owners and assigns recovery to the manager fallback", async () => { - await enableAutoRecovery(); - const { companyId, managerId, coderId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const issueTimestamp = new Date(Date.now() - 25 * 60 * 60 * 1000); - await db - .update(issues) - .set({ - status: "in_review", - assigneeAgentId: coderId, - updatedAt: issueTimestamp, - }) - .where(eq(issues.id, blockerIssueId)); - await db.insert(budgetPolicies).values({ - companyId, - scopeType: "agent", - scopeId: coderId, - metric: "billed_cents", - windowKind: "calendar_month_utc", - amount: 1, - hardStopEnabled: true, - isActive: true, - }); - await db.insert(costEvents).values({ - companyId, - agentId: coderId, - issueId: blockerIssueId, - provider: "test", - biller: "test", - billingType: "tokens", - model: "test-model", - costCents: 1, - occurredAt: new Date(), - }); - - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); - - expect(result.escalationsCreated).toBe(1); - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(1); - expect(escalations[0]).toMatchObject({ - parentId: blockerIssueId, - assigneeAgentId: managerId, - originId: [ - "harness_liveness", - companyId, - blockedIssueId, - "in_review_without_action_path", - blockerIssueId, - ].join(":"), - }); - - const events = await db.select().from(activityLog).where(eq(activityLog.companyId, companyId)); - const createdEvent = events.find((event) => event.action === "issue.harness_liveness_escalation_created"); - expect(createdEvent?.details).toMatchObject({ - ownerSelection: { - selectedAgentId: managerId, - selectedReason: "assignee_reporting_chain", - budgetBlockedCandidateAgentIds: [coderId], - }, - }); - }); - - it("parents recovery under the leaf blocker without inheriting dependent or blocker execution state for manager-owned recovery", async () => { - await enableAutoRecovery(); - await instanceSettingsService(db).updateExperimental({ enableIsolatedWorkspaces: true }); - - const companyId = randomUUID(); - const managerId = randomUUID(); - const blockedIssueId = randomUUID(); - const blockerIssueId = randomUUID(); - const dependentProjectId = randomUUID(); - const blockerProjectId = randomUUID(); - const dependentProjectWorkspaceId = randomUUID(); - const blockerProjectWorkspaceId = randomUUID(); - const dependentExecutionWorkspaceId = randomUUID(); - const blockerExecutionWorkspaceId = randomUUID(); - const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; - const issueTimestamp = new Date(Date.now() - 60 * 60 * 1000); - - await db.insert(companies).values({ - id: companyId, - name: "Paperclip", - issuePrefix, - requireBoardApprovalForNewAgents: false, - }); - await db.insert(agents).values({ - id: managerId, - companyId, - name: "Root Operator", - role: "operator", - status: "idle", - adapterType: "codex_local", - adapterConfig: {}, - runtimeConfig: { heartbeat: { wakeOnDemand: false } }, - permissions: {}, - }); - await db.insert(projects).values([ - { - id: dependentProjectId, - companyId, - name: "Dependent workspace project", - status: "in_progress", - }, - { - id: blockerProjectId, - companyId, - name: "Blocker workspace project", - status: "in_progress", - }, - ]); - await db.insert(projectWorkspaces).values([ - { - id: dependentProjectWorkspaceId, - companyId, - projectId: dependentProjectId, - name: "Dependent primary", - }, - { - id: blockerProjectWorkspaceId, - companyId, - projectId: blockerProjectId, - name: "Blocker primary", - }, - ]); - await db.insert(executionWorkspaces).values([ - { - id: dependentExecutionWorkspaceId, - companyId, - projectId: dependentProjectId, - projectWorkspaceId: dependentProjectWorkspaceId, - mode: "operator_branch", - strategyType: "git_worktree", - name: "Dependent branch", - status: "active", - providerType: "git_worktree", - }, - { - id: blockerExecutionWorkspaceId, - companyId, - projectId: blockerProjectId, - projectWorkspaceId: blockerProjectWorkspaceId, - mode: "operator_branch", - strategyType: "git_worktree", - name: "Blocker branch", - status: "active", - providerType: "git_worktree", - }, - ]); - await db.insert(issues).values([ - { - id: blockedIssueId, - companyId, - projectId: dependentProjectId, - projectWorkspaceId: dependentProjectWorkspaceId, - executionWorkspaceId: dependentExecutionWorkspaceId, - executionWorkspacePreference: "reuse_existing", - executionWorkspaceSettings: { mode: "operator_branch" }, - title: "Blocked dependent", - status: "blocked", - priority: "medium", - issueNumber: 1, - identifier: `${issuePrefix}-1`, - createdAt: issueTimestamp, - updatedAt: issueTimestamp, - }, - { - id: blockerIssueId, - companyId, - projectId: blockerProjectId, - projectWorkspaceId: blockerProjectWorkspaceId, - executionWorkspaceId: blockerExecutionWorkspaceId, - executionWorkspacePreference: "reuse_existing", - executionWorkspaceSettings: { mode: "operator_branch" }, - title: "Unassigned leaf blocker", - status: "todo", - priority: "medium", - issueNumber: 2, - identifier: `${issuePrefix}-2`, - createdAt: issueTimestamp, - updatedAt: issueTimestamp, - }, - ]); - await db.insert(issueRelations).values({ - companyId, - issueId: blockerIssueId, - relatedIssueId: blockedIssueId, - type: "blocks", - }); - - const result = await heartbeatService(db).reconcileIssueGraphLiveness(); - - expect(result.escalationsCreated).toBe(1); - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(1); - expect(escalations[0]).toMatchObject({ - parentId: blockerIssueId, - projectId: blockerProjectId, - projectWorkspaceId: blockerProjectWorkspaceId, - executionWorkspaceId: null, - executionWorkspacePreference: null, - assigneeAgentId: managerId, - assigneeAdapterOverrides: { modelProfile: "cheap" }, - }); - }); - - it("reuses one open recovery issue for multiple dependents with the same leaf blocker", async () => { - await enableAutoRecovery(); - const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const secondBlockedIssueId = randomUUID(); - const issuePrefix = `T${companyId.replace(/-/g, "").slice(0, 6).toUpperCase()}`; - const issueTimestamp = new Date(Date.now() - 60 * 60 * 1000); - await db.insert(issues).values({ - id: secondBlockedIssueId, - companyId, - title: "Second blocked parent", - status: "blocked", - priority: "medium", - issueNumber: 3, - identifier: `${issuePrefix}-3`, - createdAt: issueTimestamp, - updatedAt: issueTimestamp, - }); - await db.insert(issueRelations).values({ - companyId, - issueId: blockerIssueId, - relatedIssueId: secondBlockedIssueId, - type: "blocks", - }); - const heartbeat = heartbeatService(db); - - const result = await heartbeat.reconcileIssueGraphLiveness(); - - expect(result.findings).toBe(2); - expect(result.escalationsCreated).toBe(1); - expect(result.existingEscalations).toBe(1); - const escalations = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, companyId), eq(issues.originKind, "harness_liveness_escalation"))); - expect(escalations).toHaveLength(1); - - const blockers = await db - .select({ blockedIssueId: issueRelations.relatedIssueId }) - .from(issueRelations) - .where(and(eq(issueRelations.companyId, companyId), eq(issueRelations.issueId, escalations[0]!.id))); - expect(blockers.map((row) => row.blockedIssueId).sort()).toEqual( - [blockedIssueId, secondBlockedIssueId].sort(), - ); - }); - - it("holds a recently closed matching escalation, then re-escalates after the cooldown", async () => { - await enableAutoRecovery(); - const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const heartbeat = heartbeatService(db); - const now = new Date(); - const incidentKey = [ - "harness_liveness", - companyId, - blockedIssueId, - "blocked_by_unassigned_issue", - blockerIssueId, - ].join(":"); - const closedEscalationId = randomUUID(); - - await db.insert(issues).values({ - id: closedEscalationId, - companyId, - title: "Closed escalation", - status: "done", - priority: "high", - parentId: blockedIssueId, - assigneeAgentId: managerId, - issueNumber: 3, - identifier: "CLOSED-3", - originKind: "harness_liveness_escalation", - originId: incidentKey, - createdAt: new Date(now.getTime() - 30 * 60 * 1000), - updatedAt: now, - }); - - const held = await heartbeat.reconcileIssueGraphLiveness({ now }); - - expect(held.escalationsCreated).toBe(0); - expect(held.skippedReescalationCooldown).toBe(1); - - const result = await heartbeat.reconcileIssueGraphLiveness({ - now: new Date(now.getTime() + DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS + 1), - }); - - expect(result.escalationsCreated).toBe(1); - expect(result.existingEscalations).toBe(0); - - const openEscalations = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "harness_liveness_escalation"), - eq(issues.originId, incidentKey), - ), - ); - expect(openEscalations).toHaveLength(2); - const freshEscalation = openEscalations.find((issue) => issue.status !== "done"); - expect(freshEscalation).toMatchObject({ - parentId: blockerIssueId, - assigneeAgentId: managerId, - status: expect.stringMatching(/^(todo|in_progress|done)$/), - }); - - const blockers = await db - .select({ blockerIssueId: issueRelations.issueId }) - .from(issueRelations) - .where(eq(issueRelations.relatedIssueId, blockedIssueId)); - expect(blockers.some((row) => row.blockerIssueId === closedEscalationId)).toBe(false); - expect(blockers.some((row) => row.blockerIssueId === freshEscalation?.id)).toBe(true); - }); - - it("re-escalates immediately after a matching escalation is cancelled", async () => { - await enableAutoRecovery(); - const { companyId, managerId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const heartbeat = heartbeatService(db); - const now = new Date(); - const incidentKey = [ - "harness_liveness", - companyId, - blockedIssueId, - "blocked_by_unassigned_issue", - blockerIssueId, - ].join(":"); - - await db.insert(issues).values({ - id: randomUUID(), - companyId, - title: "Cancelled escalation", - status: "cancelled", - priority: "high", - parentId: blockedIssueId, - assigneeAgentId: managerId, - issueNumber: 3, - identifier: "CANCELLED-3", - originKind: "harness_liveness_escalation", - originId: incidentKey, - createdAt: new Date(now.getTime() - 30 * 60 * 1000), - updatedAt: now, - }); - - const result = await heartbeat.reconcileIssueGraphLiveness({ now }); - - expect(result.escalationsCreated).toBe(1); - expect(result.skippedReescalationCooldown).toBe(0); - }); - - it("removes closed liveness escalations from blocker relations during reconciliation", async () => { - await enableAutoRecovery(); - const { companyId, blockedIssueId, blockerIssueId } = await seedBlockedChain(); - const heartbeat = heartbeatService(db); - - const first = await heartbeat.reconcileIssueGraphLiveness(); - expect(first.escalationsCreated).toBe(1); - - const escalations = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, "harness_liveness_escalation"), - ), - ); - expect(escalations).toHaveLength(1); - - await db - .update(issues) - .set({ status: "done", blockedByIssueIds: [] }) - .where(eq(issues.id, escalations[0]!.id)); - await db - .update(issues) - .set({ status: "done", blockedByIssueIds: [] }) - .where(eq(issues.id, blockerIssueId)); - - const second = await heartbeat.reconcileIssueGraphLiveness(); - expect(second.obsoleteRecoveryBlockerRelationsRemoved).toBe(0); - expect(second.doneRecoveryBlockerRelationsRemoved).toBe(1); - - const blockers = await db - .select({ blockerIssueId: issueRelations.issueId }) - .from(issueRelations) - .where(eq(issueRelations.relatedIssueId, blockedIssueId)); - expect(blockers.some((row) => row.blockerIssueId === escalations[0]!.id)).toBe(false); - }); - - it("handles an armed cutoff when no liveness findings exist", async () => { - const heartbeat = heartbeatService(db); - - const result = await heartbeat.reconcileIssueGraphLiveness({ - issueCreatedAtGte: new Date(), - }); - - expect(result.findings).toBe(0); - }); }); diff --git a/server/src/__tests__/instance-settings-routes.test.ts b/server/src/__tests__/instance-settings-routes.test.ts index 56e6908808..455fbeb4fc 100644 --- a/server/src/__tests__/instance-settings-routes.test.ts +++ b/server/src/__tests__/instance-settings-routes.test.ts @@ -12,8 +12,6 @@ const mockInstanceSettingsService = vi.hoisted(() => ({ listCompanyIds: vi.fn(), })); const mockHeartbeatService = vi.hoisted(() => ({ - buildIssueGraphLivenessAutoRecoveryPreview: vi.fn(), - reconcileIssueGraphLiveness: vi.fn(), computeTaskDrain: vi.fn(), applyTaskDrain: vi.fn(), stopTaskDrain: vi.fn(), @@ -84,8 +82,6 @@ describe("instance settings routes", () => { mockInstanceSettingsService.updateGeneral.mockReset(); mockInstanceSettingsService.updateExperimental.mockReset(); mockInstanceSettingsService.listCompanyIds.mockReset(); - mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockReset(); - mockHeartbeatService.reconcileIssueGraphLiveness.mockReset(); mockHeartbeatService.computeTaskDrain.mockReset(); mockHeartbeatService.applyTaskDrain.mockReset(); mockHeartbeatService.stopTaskDrain.mockReset(); @@ -122,13 +118,11 @@ describe("instance settings routes", () => { enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, - issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T00:00:00.000Z", @@ -143,20 +137,17 @@ describe("instance settings routes", () => { enableIsolatedWorkspaces: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, - enableTaskWatchdogs: false, enableExternalObjects: false, enableBuiltInAgents: false, enableBetaSkills: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, - issueGraphLivenessAutoRecoveryLookbackHours: 24, }); mockInstanceSettingsService.update.mockResolvedValue({ id: "instance-settings-1", @@ -177,13 +168,11 @@ describe("instance settings routes", () => { enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, - issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: "2026-06-20T00:00:00.000Z", updatedAt: "2026-06-20T01:00:00.000Z", @@ -203,43 +192,19 @@ describe("instance settings routes", () => { enableIsolatedWorkspaces: true, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, - enableTaskWatchdogs: true, enableExternalObjects: false, enableBuiltInAgents: true, enableGoalsSidebarLink: false, enableServerInfoDebugView: true, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, - issueGraphLivenessAutoRecoveryLookbackHours: 24, }, }); mockInstanceSettingsService.listCompanyIds.mockResolvedValue(["company-1", "company-2"]); - mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview.mockResolvedValue({ - lookbackHours: 24, - cutoff: "2026-04-26T12:00:00.000Z", - generatedAt: "2026-04-27T12:00:00.000Z", - findings: 1, - recoverableFindings: 1, - skippedOutsideLookback: 0, - items: [], - }); - mockHeartbeatService.reconcileIssueGraphLiveness.mockResolvedValue({ - findings: 1, - autoRecoveryEnabled: true, - lookbackHours: 24, - cutoff: "2026-04-26T12:00:00.000Z", - escalationsCreated: 1, - existingEscalations: 0, - skipped: 0, - skippedAutoRecoveryDisabled: 0, - skippedOutsideLookback: 0, - escalationIssueIds: ["issue-2"], - }); mockEnvironmentService.getById.mockResolvedValue({ id: "env-1", driver: "local", @@ -263,20 +228,17 @@ describe("instance settings routes", () => { enableIsolatedWorkspaces: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, - enableTaskWatchdogs: false, enableExternalObjects: false, enableBuiltInAgents: false, enableBetaSkills: false, enableGoalsSidebarLink: false, enableServerInfoDebugView: false, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, - issueGraphLivenessAutoRecoveryLookbackHours: 24, }); const patchRes = await request(app) @@ -290,6 +252,24 @@ describe("instance settings routes", () => { expect(mockLogActivity).toHaveBeenCalledTimes(2); }, 10_000); + it("does not expose the retired liveness auto-recovery endpoints", async () => { + const app = await createApp({ + type: "board", + userId: "local-board", + source: "local_implicit", + isInstanceAdmin: true, + }); + + await request(app) + .post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview") + .send({ lookbackHours: 24 }) + .expect(404); + await request(app) + .post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run") + .send({ lookbackHours: 24 }) + .expect(404); + }); + it("strips server-managed worktree run execution fields before updating experimental settings", async () => { const app = await createApp({ type: "board", @@ -514,68 +494,6 @@ describe("instance settings routes", () => { }); }); - it("allows local board users to update issue graph liveness auto-recovery", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); - - await request(app) - .patch("/api/instance/settings/experimental") - .send({ - enableIssueGraphLivenessAutoRecovery: true, - issueGraphLivenessAutoRecoveryLookbackHours: 12, - }) - .expect(200); - - expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ - enableIssueGraphLivenessAutoRecovery: true, - issueGraphLivenessAutoRecoveryLookbackHours: 12, - }); - }); - - it("previews issue graph liveness recovery candidates before enabling", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); - - const res = await request(app) - .post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview") - .send({ lookbackHours: 12 }) - .expect(200); - - expect(res.body).toMatchObject({ lookbackHours: 24, recoverableFindings: 1 }); - expect(mockHeartbeatService.buildIssueGraphLivenessAutoRecoveryPreview).toHaveBeenCalledWith({ - lookbackHours: 12, - }); - }); - - it("kicks off issue graph liveness recovery on demand", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); - - await request(app) - .post("/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run") - .send({ lookbackHours: 12 }) - .expect(200); - - expect(mockHeartbeatService.reconcileIssueGraphLiveness).toHaveBeenCalledWith({ - runId: null, - force: true, - lookbackHours: 12, - }); - expect(mockLogActivity).toHaveBeenCalledTimes(2); - }); - it("allows local board users to update environment controls", async () => { const app = await createApp({ type: "board", @@ -594,24 +512,6 @@ describe("instance settings routes", () => { }); }); - it("allows local board users to update task watchdog controls", async () => { - const app = await createApp({ - type: "board", - userId: "local-board", - source: "local_implicit", - isInstanceAdmin: true, - }); - - await request(app) - .patch("/api/instance/settings/experimental") - .send({ enableTaskWatchdogs: true }) - .expect(200); - - expect(mockInstanceSettingsService.updateExperimental).toHaveBeenCalledWith({ - enableTaskWatchdogs: true, - }); - }); - it("allows non-admin board users with company access to read but not update experimental settings", async () => { const app = await createApp({ type: "board", @@ -625,7 +525,7 @@ describe("instance settings routes", () => { await request(app) .patch("/api/instance/settings/experimental") - .send({ enableTaskWatchdogs: true }) + .send({ enableEnvironments: true }) .expect(403); expect(mockInstanceSettingsService.updateExperimental).not.toHaveBeenCalled(); diff --git a/server/src/__tests__/instance-settings-service.test.ts b/server/src/__tests__/instance-settings-service.test.ts index ed93d875ae..8d306d0c10 100644 --- a/server/src/__tests__/instance-settings-service.test.ts +++ b/server/src/__tests__/instance-settings-service.test.ts @@ -15,16 +15,13 @@ describe("instance settings service", () => { enableIsolatedWorkspaces: true, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, - enableTaskWatchdogs: true, enableBuiltInAgents: true, enableGoalsSidebarLink: true, enableServerInfoDebugView: true, enablePaperclipDeveloperMode: true, autoRestartDevServerWhenIdle: true, - enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: false, - issueGraphLivenessAutoRecoveryLookbackHours: 48, enableNewestFirstIssueThread: true, })).toEqual({ enableEnvironments: true, @@ -41,7 +38,6 @@ describe("instance settings service", () => { enableCases: false, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: true, - enableTaskWatchdogs: true, enableBuiltInAgents: true, enableBetaSkills: false, enableSummaries: false, @@ -52,7 +48,6 @@ describe("instance settings service", () => { enablePaperclipDeveloperMode: true, enableSimplifiedEnglishInteractions: false, autoRestartDevServerWhenIdle: true, - enableIssueGraphLivenessAutoRecovery: true, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: false, enableOwnerInstanceAdmin: false, @@ -61,7 +56,6 @@ describe("instance settings service", () => { enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, - issueGraphLivenessAutoRecoveryLookbackHours: 48, }); }); @@ -114,14 +108,6 @@ describe("instance settings service", () => { ).toBe(true); }); - it("defaults enableTaskWatchdogs to false for empty and legacy stored settings", () => { - expect(normalizeExperimentalSettings(undefined).enableTaskWatchdogs).toBe(false); - expect(normalizeExperimentalSettings({}).enableTaskWatchdogs).toBe(false); - expect( - normalizeExperimentalSettings({ enableExperimentalFileViewer: true }).enableTaskWatchdogs, - ).toBe(false); - }); - it("defaults enableSmokeLab to false for empty and legacy stored settings", () => { expect(normalizeExperimentalSettings(undefined).enableSmokeLab).toBe(false); expect(normalizeExperimentalSettings({}).enableSmokeLab).toBe(false); @@ -167,7 +153,7 @@ describe("instance settings service", () => { expect(normalizeExperimentalSettings(undefined).enableWorkspaceBranchReconcileForward).toBe(true); expect(normalizeExperimentalSettings({}).enableWorkspaceBranchReconcileForward).toBe(true); expect( - normalizeExperimentalSettings({ enableIssueGraphLivenessAutoRecovery: true }) + normalizeExperimentalSettings({ enableExperimentalFileViewer: true }) .enableWorkspaceBranchReconcileForward, ).toBe(true); expect(normalizeExperimentalSettings(undefined).enableWorkspaceDirtyQuarantineRepair).toBe(true); diff --git a/server/src/__tests__/managed-config.test.ts b/server/src/__tests__/managed-config.test.ts index b8362381f8..4f82706352 100644 --- a/server/src/__tests__/managed-config.test.ts +++ b/server/src/__tests__/managed-config.test.ts @@ -29,7 +29,6 @@ describe("managedFeatureKeySet", () => { // Server-managed bookkeeping fields are not overlayable features. expect(keys.has("worktreeRunExecutionActivatedAt")).toBe(false); expect(keys.has("worktreeRunExecutionActivationInstanceId")).toBe(false); - expect(keys.has("issueGraphLivenessAutoRecoveryLookbackHours")).toBe(false); }); }); diff --git a/server/src/__tests__/server-startup-feedback-export.test.ts b/server/src/__tests__/server-startup-feedback-export.test.ts index 015faec1c3..06adc77731 100644 --- a/server/src/__tests__/server-startup-feedback-export.test.ts +++ b/server/src/__tests__/server-startup-feedback-export.test.ts @@ -61,10 +61,7 @@ const { skipped: 0, issueIds: [], })), - reconcileIssueGraphLiveness: vi.fn(async () => ({ - escalationsCreated: 0, - dependencyWakesHealed: 0, - })), + reconcileResolvedDependencyWakes: vi.fn(async () => ({ healed: 0 })), reconcileTaskWatchdogs: vi.fn(async () => ({ triggered: 0 })), scanSilentActiveRuns: vi.fn(async () => ({ created: 0, escalated: 0 })), sweepStaleIssueLocks: vi.fn(async () => ({ cleared: 0 })), diff --git a/server/src/index.ts b/server/src/index.ts index 6d85140dbd..d114eb8815 100644 --- a/server/src/index.ts +++ b/server/src/index.ts @@ -1316,11 +1316,11 @@ export async function startServer(): Promise { ); } - const issueGraphReconciled = await heartbeat.reconcileIssueGraphLiveness(); - if (issueGraphReconciled.escalationsCreated > 0 || issueGraphReconciled.dependencyWakesHealed > 0) { + const dependencyWakesReconciled = await heartbeat.reconcileResolvedDependencyWakes(); + if (dependencyWakesReconciled.healed > 0) { logger.warn( - { ...issueGraphReconciled }, - "startup issue-graph liveness reconciliation changed issue graph state", + { ...dependencyWakesReconciled }, + "startup dependency-wake reconciliation restored task execution paths", ); } @@ -1556,9 +1556,9 @@ export async function startServer(): Promise { } }) .then(async () => { - const reconciled = await heartbeat.reconcileIssueGraphLiveness(); - if (reconciled.escalationsCreated > 0 || reconciled.dependencyWakesHealed > 0) { - logger.warn({ ...reconciled }, "periodic issue-graph liveness reconciliation changed issue graph state"); + const reconciled = await heartbeat.reconcileResolvedDependencyWakes(); + if (reconciled.healed > 0) { + logger.warn({ ...reconciled }, "periodic dependency-wake reconciliation restored task execution paths"); } }) .then(async () => { diff --git a/server/src/routes/instance-settings.ts b/server/src/routes/instance-settings.ts index e7cb6d30e9..1784d848c6 100644 --- a/server/src/routes/instance-settings.ts +++ b/server/src/routes/instance-settings.ts @@ -1,7 +1,6 @@ import { Router, type Request } from "express"; import type { Db } from "@paperclipai/db"; import { - issueGraphLivenessAutoRecoveryRequestSchema, patchInstanceSettingsSchema, patchInstanceExperimentalSettingsSchema, patchInstanceGeneralSettingsSchema, @@ -291,55 +290,6 @@ export function instanceSettingsRoutes(db: Db) { }, ); - router.post( - "/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview", - validate(issueGraphLivenessAutoRecoveryRequestSchema), - async (req, res) => { - assertCanManageInstanceSettings(req); - res.json(await heartbeat.buildIssueGraphLivenessAutoRecoveryPreview({ - lookbackHours: req.body.lookbackHours, - })); - }, - ); - - router.post( - "/instance/settings/experimental/issue-graph-liveness-auto-recovery/run", - validate(issueGraphLivenessAutoRecoveryRequestSchema), - async (req, res) => { - assertCanManageInstanceSettings(req); - const actor = getActorInfo(req); - const result = await heartbeat.reconcileIssueGraphLiveness({ - runId: actor.runId, - force: true, - lookbackHours: req.body.lookbackHours, - }); - const companyIds = await svc.listCompanyIds(); - await Promise.all( - companyIds.map((companyId) => - logActivity(db, { - companyId, - actorType: actor.actorType, - actorId: actor.actorId, - agentId: actor.agentId, - runId: actor.runId, - agentApiKeyId: actor.agentApiKeyId, - action: "instance.settings.issue_graph_liveness_auto_recovery_run", - entityType: "instance_settings", - entityId: "default", - details: { - lookbackHours: result.lookbackHours, - escalationsCreated: result.escalationsCreated, - existingEscalations: result.existingEscalations, - skippedOutsideLookback: result.skippedOutsideLookback, - escalationIssueIds: result.escalationIssueIds, - }, - }), - ), - ); - res.json(result); - }, - ); - router.get("/instance/task-drain", async (req, res) => { assertBoardOrgAccess(req); res.json(heartbeat.getTaskDrainStatus()); diff --git a/server/src/routes/openapi.ts b/server/src/routes/openapi.ts index 03e1ee1a5b..7f7b1ff036 100644 --- a/server/src/routes/openapi.ts +++ b/server/src/routes/openapi.ts @@ -172,7 +172,6 @@ import { patchInstanceGeneralSettingsSchema, patchInstanceExperimentalSettingsSchema, patchInstanceSettingsSchema, - issueGraphLivenessAutoRecoveryRequestSchema, startTaskDrainRequestSchema, // Resource memberships updateDocumentResourceMembershipSchema, @@ -7062,22 +7061,6 @@ for (const route of [ }); } -registerCurrentRoute({ - method: "post", - path: "/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview", - tags: ["instance-settings"], - summary: "Preview issue graph liveness auto-recovery", - body: issueGraphLivenessAutoRecoveryRequestSchema, -}); - -registerCurrentRoute({ - method: "post", - path: "/api/instance/settings/experimental/issue-graph-liveness-auto-recovery/run", - tags: ["instance-settings"], - summary: "Run issue graph liveness auto-recovery", - body: issueGraphLivenessAutoRecoveryRequestSchema, -}); - registerCurrentRoute({ method: "get", path: "/api/issues/{id}/accepted-plan-decompositions", diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 20a0e3a0d6..3a02b0d3db 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -17067,24 +17067,11 @@ export function heartbeatService( return recovery.buildRunOutputSilence(run, now); } - async function buildIssueGraphLivenessAutoRecoveryPreview(opts?: { - lookbackHours?: number; - now?: Date; - }) { - return recovery.buildIssueGraphLivenessAutoRecoveryPreview(opts); - } - - async function reconcileIssueGraphLiveness(opts?: { + async function reconcileResolvedDependencyWakes(opts?: { runId?: string | null; - force?: boolean; - lookbackHours?: number; - now?: Date; - reescalationCooldownMs?: number; + companyId?: string | null; }) { - return recovery.reconcileIssueGraphLiveness({ - ...opts, - issueCreatedAtGte: await getWorktreeExecutionCutoff(), - }); + return recovery.reconcileResolvedDependencyWakeBackstop(opts); } async function updateRuntimeState( @@ -25489,9 +25476,7 @@ export function heartbeatService( sweepStaleIssueLocks, - buildIssueGraphLivenessAutoRecoveryPreview, - - reconcileIssueGraphLiveness, + reconcileResolvedDependencyWakes, scanSilentActiveRuns, diff --git a/server/src/services/instance-settings.ts b/server/src/services/instance-settings.ts index dc279f78d4..feb04ef33c 100644 --- a/server/src/services/instance-settings.ts +++ b/server/src/services/instance-settings.ts @@ -14,7 +14,6 @@ export type InstanceSettingsWriteDb = Pick< import { DEFAULT_FEEDBACK_DATA_SHARING_PREFERENCE, DEFAULT_BACKUP_RETENTION, - DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, PAPERCLIP_CLOUD_MANAGED_BY, instanceGeneralSettingsSchema, type InstanceGeneralSettings, @@ -232,7 +231,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableClassicTaskInterface: parsed.data.enableClassicTaskInterface ?? false, enableIssuePlanDecompositions: parsed.data.enableIssuePlanDecompositions ?? false, enableExperimentalFileViewer: parsed.data.enableExperimentalFileViewer ?? false, - enableTaskWatchdogs: parsed.data.enableTaskWatchdogs ?? false, enableExternalObjects: parsed.data.enableExternalObjects ?? false, enableSmokeLab: parsed.data.enableSmokeLab ?? false, enableBuiltInAgents: parsed.data.enableBuiltInAgents ?? false, @@ -245,7 +243,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePaperclipDeveloperMode: parsed.data.enablePaperclipDeveloperMode ?? false, enableSimplifiedEnglishInteractions: parsed.data.enableSimplifiedEnglishInteractions ?? false, autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false, - enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false, enableWorkspaceBranchReconcileForward: parsed.data.enableWorkspaceBranchReconcileForward ?? true, enableWorkspaceDirtyQuarantineRepair: parsed.data.enableWorkspaceDirtyQuarantineRepair ?? true, enableOwnerInstanceAdmin: parsed.data.enableOwnerInstanceAdmin ?? false, @@ -255,9 +252,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta worktreeRunExecutionActivatedAt: parsed.data.worktreeRunExecutionActivatedAt ?? null, worktreeRunExecutionActivationInstanceId: parsed.data.worktreeRunExecutionActivationInstanceId ?? null, - issueGraphLivenessAutoRecoveryLookbackHours: - parsed.data.issueGraphLivenessAutoRecoveryLookbackHours ?? - DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, }; } return { @@ -271,7 +265,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableCases: false, enableConferenceRoomChat: false, enableClassicTaskInterface: false, - enableTaskWatchdogs: false, enableIssuePlanDecompositions: false, enableExperimentalFileViewer: false, enableExternalObjects: false, @@ -286,7 +279,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enablePaperclipDeveloperMode: false, enableSimplifiedEnglishInteractions: false, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: false, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableOwnerInstanceAdmin: false, @@ -295,8 +287,6 @@ export function normalizeExperimentalSettings(raw: unknown): InstanceExperimenta enableWorktreeRunExecution: false, worktreeRunExecutionActivatedAt: null, worktreeRunExecutionActivationInstanceId: null, - issueGraphLivenessAutoRecoveryLookbackHours: - DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, }; } diff --git a/server/src/services/recovery/service.ts b/server/src/services/recovery/service.ts index 1e28caff56..92cafb4ca5 100644 --- a/server/src/services/recovery/service.ts +++ b/server/src/services/recovery/service.ts @@ -1,15 +1,10 @@ import { and, asc, desc, eq, gt, gte, inArray, isNull, notInArray, or, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { - DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, - MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, - MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, PROVIDER_QUOTA_MONITOR_SERVICE_NAME, ISSUE_DISPOSITION_REPAIR_RETRY_REASON, type IssueCommentMetadata, type IssueCommentPresentation, - type IssueGraphLivenessAutoRecoveryPreview, - type IssueGraphLivenessAutoRecoveryPreviewItem, } from "@paperclipai/shared"; import { agents, @@ -39,7 +34,6 @@ import { isUniqueViolation } from "../../db-errors.js"; import { logActivity } from "../activity-log.js"; import { appendHeartbeatRunEvent } from "../heartbeat-run-events.js"; import { budgetService } from "../budgets.js"; -import { instanceSettingsService } from "../instance-settings.js"; import { issueRecoveryActionService } from "../issue-recovery-actions.js"; import { issueTreeControlService } from "../issue-tree-control.js"; import { TERMINAL_HEARTBEAT_RUN_STATUSES, issueService } from "../issues.js"; @@ -72,14 +66,8 @@ import { } from "./stranded-notice.js"; import { RECOVERY_ORIGIN_KINDS, - buildIssueGraphLivenessLeafKey, isStrandedIssueRecoveryOriginKind, - parseIssueGraphLivenessIncidentKey, } from "./origins.js"; -import { - classifyIssueGraphLiveness, - type IssueLivenessFinding, -} from "./issue-graph-liveness.js"; import { recoveryAssigneeAdapterOverrides, withRecoveryModelProfileHint, @@ -96,7 +84,6 @@ const UNSUCCESSFUL_HEARTBEAT_RUN_TERMINAL_STATUSES = ["interrupted", "failed", " 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_CONTINUE_REARM_MS = 30 * 60 * 1000; -export const DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS = 60 * 60 * 1000; const STRANDED_ISSUE_RECOVERY_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.strandedIssueRecovery; const STALE_ACTIVE_RUN_EVALUATION_ORIGIN_KIND = RECOVERY_ORIGIN_KINDS.staleActiveRunEvaluation; const DEFERRED_WAKE_CONTEXT_KEY = "_paperclipWakeContext"; @@ -681,99 +668,11 @@ function isRepeatedProductiveContinuationRecovery(latestRun: SuccessfulLatestIss isProductiveContinuationRun(latestRun); } -function parseLivenessIncidentKey(incidentKey: string | null | undefined) { - if (!incidentKey) return null; - return parseIssueGraphLivenessIncidentKey(incidentKey); -} - -function livenessRecoveryLeafIssueId(finding: IssueLivenessFinding) { - return finding.recoveryIssueId; -} - -function livenessRecoveryLeafFingerprint(finding: IssueLivenessFinding) { - return buildIssueGraphLivenessLeafKey({ - companyId: finding.companyId, - state: finding.state, - leafIssueId: livenessRecoveryLeafIssueId(finding), - }); -} - -function livenessRecoveryLeafKey(companyId: string, state: string, leafIssueId: string) { - return buildIssueGraphLivenessLeafKey({ companyId, state, leafIssueId }); -} - -function isUniqueLivenessRecoveryConflict(error: unknown) { - if (!error || typeof error !== "object") return false; - const maybe = error as { code?: string; constraint?: string; message?: string }; - return maybe.code === "23505" && - ( - maybe.constraint === "issues_active_liveness_recovery_incident_uq" || - maybe.constraint === "issues_active_liveness_recovery_leaf_uq" || - typeof maybe.message === "string" && - ( - maybe.message.includes("issues_active_liveness_recovery_incident_uq") || - maybe.message.includes("issues_active_liveness_recovery_leaf_uq") - ) - ); -} - -function formatDependencyPath(finding: IssueLivenessFinding) { - return finding.dependencyPath - .map((entry) => entry.identifier ?? entry.issueId) - .join(" -> "); -} - -function buildLivenessEscalationDescription(finding: IssueLivenessFinding) { - const source = finding.dependencyPath[0]; - const recovery = finding.dependencyPath.find((entry) => entry.issueId === finding.recoveryIssueId); - const selectedOwner = finding.recommendedOwnerAgentId ?? "none"; - - return [ - "Paperclip detected a harness-level issue graph liveness incident.", - "", - "## Source", - "", - `- Source issue: ${source?.identifier ?? source?.issueId ?? finding.issueId}`, - `- Recovery target issue: ${recovery?.identifier ?? recovery?.issueId ?? finding.recoveryIssueId}`, - `- Incident key: \`${finding.incidentKey}\``, - `- Detected invariant: \`${finding.state}\``, - `- Dependency path: ${formatDependencyPath(finding)}`, - `- Reason: ${finding.reason}`, - "", - "## Ownership", - "", - `- Selected owner agent: \`${selectedOwner}\``, - `- Candidate owner agents: ${finding.recommendedOwnerCandidateAgentIds.length > 0 ? finding.recommendedOwnerCandidateAgentIds.map((id) => `\`${id}\``).join(", ") : "none"}`, - "", - "## Next Action", - "", - finding.recommendedAction, - "", - "Resolve the blocked chain, then mark this escalation issue done so the original issue can resume when all blockers are cleared.", - ].join("\n"); -} - -function buildLivenessOriginalIssueComment(finding: IssueLivenessFinding, escalation: typeof issues.$inferSelect) { - return [ - "Paperclip detected a harness-level liveness incident in this issue's dependency graph.", - "", - `- Escalation issue: ${escalation.identifier ?? escalation.id}`, - `- Incident key: \`${finding.incidentKey}\``, - `- Finding: \`${finding.state}\``, - `- Dependency path: ${formatDependencyPath(finding)}`, - `- Reason: ${finding.reason}`, - `- Manager action requested: ${finding.recommendedAction}`, - "", - "This issue now keeps its existing blockers and is also blocked by the escalation issue so dependency wakeups remain explicit.", - ].join("\n"); -} - export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) { const issuesSvc = issueService(db); const recoveryActionsSvc = issueRecoveryActionService(db); const treeControlSvc = issueTreeControlService(db); const budgets = budgetService(db); - const instanceSettings = instanceSettingsService(db); let resolvedDependencyWakeBackstopCandidateCursor: string | null = null; async function getAgent(agentId: string) { @@ -4344,838 +4243,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return result; } - async function collectIssueGraphLivenessFindings() { - const issueRowsPromise = Promise.resolve(db - .select({ - id: issues.id, - companyId: issues.companyId, - identifier: issues.identifier, - title: issues.title, - status: issues.status, - projectId: issues.projectId, - goalId: issues.goalId, - parentId: issues.parentId, - assigneeAgentId: issues.assigneeAgentId, - assigneeUserId: issues.assigneeUserId, - createdByAgentId: issues.createdByAgentId, - createdByUserId: issues.createdByUserId, - executionPolicy: issues.executionPolicy, - executionState: issues.executionState, - monitorNextCheckAt: issues.monitorNextCheckAt, - monitorAttemptCount: issues.monitorAttemptCount, - }) - .from(issues) - .where( - and( - visibleIssueCondition(), - notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]), - ), - )); - - const [ - issueRows, - relationRows, - agentRows, - activeRunRows, - activeIssueRunRows, - wakeRows, - interactionRows, - approvalRows, - recoveryIssueRows, - recoveryActionRows, - ] = await Promise.all([ - issueRowsPromise, - db - .select({ - companyId: issueRelations.companyId, - blockerIssueId: issueRelations.issueId, - blockedIssueId: issueRelations.relatedIssueId, - }) - .from(issueRelations) - .where(eq(issueRelations.type, "blocks")), - db - .select({ - id: agents.id, - companyId: agents.companyId, - name: agents.name, - role: agents.role, - title: agents.title, - status: agents.status, - reportsTo: agents.reportsTo, - }) - .from(agents), - db - .select({ - companyId: heartbeatRuns.companyId, - agentId: heartbeatRuns.agentId, - status: heartbeatRuns.status, - contextSnapshot: heartbeatRuns.contextSnapshot, - }) - .from(heartbeatRuns) - .where(inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES])), - db - .select({ - companyId: issues.companyId, - agentId: heartbeatRuns.agentId, - status: heartbeatRuns.status, - issueId: issues.id, - }) - .from(issues) - .innerJoin(heartbeatRuns, eq(issues.executionRunId, heartbeatRuns.id)) - .where( - and( - visibleIssueCondition(), - notInArray(issues.originKind, [RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation]), - inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), - ), - ), - db - .select({ - companyId: agentWakeupRequests.companyId, - agentId: agentWakeupRequests.agentId, - status: agentWakeupRequests.status, - payload: agentWakeupRequests.payload, - }) - .from(agentWakeupRequests) - .where(inArray(agentWakeupRequests.status, ["queued", "deferred_issue_execution"])), - db - .select({ - companyId: issueThreadInteractions.companyId, - issueId: issueThreadInteractions.issueId, - status: issueThreadInteractions.status, - }) - .from(issueThreadInteractions) - .where(eq(issueThreadInteractions.status, "pending")), - db - .select({ - companyId: issueApprovals.companyId, - issueId: issueApprovals.issueId, - status: approvals.status, - }) - .from(issueApprovals) - .innerJoin(approvals, eq(issueApprovals.approvalId, approvals.id)) - .where(inArray(approvals.status, ["pending", "revision_requested"])), - db - .select({ - companyId: issues.companyId, - id: issues.id, - status: issues.status, - originKind: issues.originKind, - originId: issues.originId, - }) - .from(issues) - .where( - and( - visibleIssueCondition(), - inArray(issues.originKind, [ - STRANDED_ISSUE_RECOVERY_ORIGIN_KIND, - RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation, - ]), - notInArray(issues.status, ["done", "cancelled"]), - ), - ), - issueRowsPromise.then((rows) => { - const issueIdsUnderAnalysis = rows.map((row) => row.id); - return issueIdsUnderAnalysis.length === 0 - ? [] - : db - .select({ - id: issueRecoveryActions.id, - companyId: issueRecoveryActions.companyId, - issueId: issueRecoveryActions.sourceIssueId, - status: issueRecoveryActions.status, - ownerType: issueRecoveryActions.ownerType, - ownerAgentId: issueRecoveryActions.ownerAgentId, - ownerUserId: issueRecoveryActions.ownerUserId, - }) - .from(issueRecoveryActions) - .where( - and( - inArray(issueRecoveryActions.status, ["active", "escalated"]), - inArray(issueRecoveryActions.sourceIssueId, issueIdsUnderAnalysis), - ), - ); - }), - ]); - - const openRecoveryIssues = recoveryIssueRows.flatMap((row) => { - if (row.originKind === RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation) { - const parsed = parseIssueGraphLivenessIncidentKey(row.originId); - if (!parsed || parsed.companyId !== row.companyId) return []; - return [ - { - companyId: row.companyId, - issueId: parsed.issueId, - status: row.status, - }, - { - companyId: row.companyId, - issueId: parsed.leafIssueId, - status: row.status, - }, - ]; - } - - const issueId = readNonEmptyString(row.originId); - if (!issueId) return []; - return [{ - companyId: row.companyId, - issueId, - status: row.status, - }]; - }); - - const liveRecoveryActionIds = new Set(); - for (const row of activeRunRows) { - const recoveryActionId = readNonEmptyString(parseObject(row.contextSnapshot).recoveryActionId); - if (recoveryActionId) liveRecoveryActionIds.add(recoveryActionId); - } - for (const row of wakeRows) { - const recoveryActionId = readNonEmptyString(parseObject(row.payload).recoveryActionId); - if (recoveryActionId) liveRecoveryActionIds.add(recoveryActionId); - } - const healthyRecoveryActions = recoveryActionRows.filter((row) => - (row.status === "escalated" && row.ownerType === "board") || - Boolean(row.ownerUserId) || - (Boolean(row.ownerAgentId) && liveRecoveryActionIds.has(row.id)), - ); - - return classifyIssueGraphLiveness({ - issues: issueRows, - relations: relationRows, - agents: agentRows, - activeRuns: activeRunRows.map((row) => ({ - companyId: row.companyId, - agentId: row.agentId, - status: row.status, - issueId: issueIdFromRunContext(row.contextSnapshot), - })).concat(activeIssueRunRows.map((row) => ({ - companyId: row.companyId, - agentId: row.agentId, - status: row.status, - issueId: row.issueId, - }))), - queuedWakeRequests: wakeRows.map((row) => ({ - companyId: row.companyId, - agentId: row.agentId, - status: row.status, - issueId: issueIdFromWakePayload(row.payload), - })), - pendingInteractions: interactionRows, - pendingApprovals: approvalRows, - openRecoveryIssues: openRecoveryIssues.concat(healthyRecoveryActions), - now: new Date(), - }); - } - - async function findOpenLivenessEscalation(companyId: string, incidentKey: string) { - return db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - eq(issues.originId, incidentKey), - visibleIssueCondition(), - notInArray(issues.status, ["done", "cancelled"]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - } - - async function findOpenLivenessRecoveryIssueForLeaf(finding: IssueLivenessFinding) { - const byFingerprint = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, finding.companyId), - eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - eq(issues.originFingerprint, livenessRecoveryLeafFingerprint(finding)), - visibleIssueCondition(), - notInArray(issues.status, ["done", "cancelled"]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null); - if (byFingerprint) return byFingerprint; - - const leafIssueId = livenessRecoveryLeafIssueId(finding); - const openRecoveries = await db - .select() - .from(issues) - .where( - and( - eq(issues.companyId, finding.companyId), - eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - visibleIssueCondition(), - notInArray(issues.status, ["done", "cancelled"]), - ), - ); - return openRecoveries.find((row) => { - const parsed = parseLivenessIncidentKey(row.originId); - return parsed?.state === finding.state && parsed.leafIssueId === leafIssueId; - }) ?? null; - } - - async function findRecentCompletedLivenessRecoveryIssue( - finding: IssueLivenessFinding, - now: Date, - cooldownMs: number, - ) { - if (cooldownMs <= 0) return null; - const cutoff = new Date(now.getTime() - cooldownMs); - return db - .select({ id: issues.id }) - .from(issues) - .where( - and( - eq(issues.companyId, finding.companyId), - eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - or( - eq(issues.originId, finding.incidentKey), - eq(issues.originFingerprint, livenessRecoveryLeafFingerprint(finding)), - ), - visibleIssueCondition(), - eq(issues.status, "done"), - gte(issues.updatedAt, cutoff), - ), - ) - .orderBy(desc(issues.updatedAt), desc(issues.id)) - .limit(1) - .then((rows) => rows[0] ?? null); - } - - async function removeRecoveryBlockerFromSource(recovery: typeof issues.$inferSelect) { - const parsed = parseLivenessIncidentKey(recovery.originId); - if (!parsed) return false; - const sourceIssue = await db - .select() - .from(issues) - .where(and(eq(issues.companyId, recovery.companyId), eq(issues.id, parsed.issueId))) - .then((rows) => rows[0] ?? null); - if (!sourceIssue) return false; - - const blockerIds = await existingBlockerIssueIds(sourceIssue.companyId, sourceIssue.id); - if (!blockerIds.includes(recovery.id)) return false; - await issuesSvc.update(sourceIssue.id, { - blockedByIssueIds: blockerIds.filter((blockerId) => blockerId !== recovery.id), - }); - return true; - } - - async function hasActiveRunForIssueId(companyId: string, issueId: string) { - const [contextRun, issueRun] = await Promise.all([ - db - .select({ id: heartbeatRuns.id }) - .from(heartbeatRuns) - .where( - and( - eq(heartbeatRuns.companyId, companyId), - inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), - sql`(${heartbeatRuns.contextSnapshot}->>'issueId' = ${issueId} - OR ${heartbeatRuns.contextSnapshot}->>'taskId' = ${issueId})`, - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null), - db - .select({ id: heartbeatRuns.id }) - .from(issues) - .innerJoin(heartbeatRuns, eq(issues.executionRunId, heartbeatRuns.id)) - .where( - and( - eq(issues.companyId, companyId), - eq(issues.id, issueId), - inArray(heartbeatRuns.status, [...EXECUTION_PATH_HEARTBEAT_RUN_STATUSES]), - ), - ) - .limit(1) - .then((rows) => rows[0] ?? null), - ]); - return Boolean(contextRun || issueRun); - } - - async function retireObsoleteLivenessRecoveryIssues(findings: IssueLivenessFinding[]) { - const currentIncidentKeys = new Set(findings.map((finding) => finding.incidentKey)); - const currentLeafKeys = new Set( - findings.map((finding) => - livenessRecoveryLeafKey( - finding.companyId, - finding.state, - livenessRecoveryLeafIssueId(finding), - ), - ), - ); - const openRecoveries = await db - .select() - .from(issues) - .where( - and( - eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - visibleIssueCondition(), - notInArray(issues.status, ["done", "cancelled"]), - ), - ); - const result = { - retired: 0, - activeSkipped: 0, - blockerRelationsRemoved: 0, - retiredIssueIds: [] as string[], - }; - - for (const recovery of openRecoveries) { - if (recovery.originId && currentIncidentKeys.has(recovery.originId)) continue; - const parsed = parseLivenessIncidentKey(recovery.originId); - if (!parsed) continue; - if ( - currentLeafKeys.has( - livenessRecoveryLeafKey(parsed.companyId, parsed.state, parsed.leafIssueId), - ) - ) { - continue; - } - const sourceIssue = await db - .select({ - id: issues.id, - status: issues.status, - }) - .from(issues) - .where(and(eq(issues.companyId, parsed.companyId), eq(issues.id, parsed.issueId))) - .then((rows) => rows[0] ?? null); - if (sourceIssue && !["done", "cancelled"].includes(sourceIssue.status)) { - const blockerIds = await existingBlockerIssueIds(parsed.companyId, sourceIssue.id); - if (blockerIds.includes(recovery.id)) { - result.activeSkipped += 1; - continue; - } - } - if (await removeRecoveryBlockerFromSource(recovery)) { - result.blockerRelationsRemoved += 1; - } - if (await hasActiveRunForIssueId(recovery.companyId, recovery.id)) { - result.activeSkipped += 1; - continue; - } - await issuesSvc.update(recovery.id, { status: "cancelled" }); - result.retired += 1; - result.retiredIssueIds.push(recovery.id); - } - - return result; - } - - async function retireDoneLivenessRecoveryBlockers() { - const closedRecoveries = await db - .select() - .from(issues) - .where( - and( - eq(issues.originKind, RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation), - visibleIssueCondition(), - inArray(issues.status, ["done", "cancelled"]), - ), - ); - - let blockerRelationsRemoved = 0; - for (const recovery of closedRecoveries) { - if (await removeRecoveryBlockerFromSource(recovery)) { - blockerRelationsRemoved += 1; - } - } - - return { blockerRelationsRemoved }; - } - - function normalizeIssueGraphLivenessAutoRecoveryLookbackHours(raw: unknown) { - const numeric = Math.floor(asNumber(raw, DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS)); - return Math.min( - MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, - Math.max(MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS, numeric), - ); - } - - function livenessDependencyIssueKey(companyId: string, issueId: string) { - return `${companyId}:${issueId}`; - } - - async function loadLivenessDependencyUpdatedAtByIssue(findings: IssueLivenessFinding[]) { - const issueIds = [ - ...new Set( - findings.flatMap((finding) => finding.dependencyPath.map((entry) => entry.issueId)), - ), - ]; - if (issueIds.length === 0) return new Map(); - const rows = await db - .select({ id: issues.id, companyId: issues.companyId, updatedAt: issues.updatedAt }) - .from(issues) - .where(inArray(issues.id, issueIds)); - return new Map(rows.map((row) => [ - livenessDependencyIssueKey(row.companyId, row.id), - row.updatedAt, - ])); - } - - function latestDependencyUpdatedAtForLivenessFinding( - finding: IssueLivenessFinding, - updatedAtByIssueKey: Map, - ) { - const dependencyIssueIds = [...new Set(finding.dependencyPath.map((entry) => entry.issueId))]; - if (dependencyIssueIds.length === 0) return null; - const timestamps = dependencyIssueIds.map((issueId) => - updatedAtByIssueKey.get(livenessDependencyIssueKey(finding.companyId, issueId)) ?? null - ); - if (timestamps.some((timestamp) => !timestamp)) return null; - const [firstTimestamp, ...remainingTimestamps] = timestamps as Date[]; - return remainingTimestamps.reduce((latest, updatedAt) => - updatedAt > latest ? updatedAt : latest, - firstTimestamp!); - } - - function isLivenessFindingInsideAutoRecoveryLookback( - finding: IssueLivenessFinding, - cutoff: Date, - updatedAtByIssueKey: Map, - ) { - const latestUpdatedAt = latestDependencyUpdatedAtForLivenessFinding(finding, updatedAtByIssueKey); - return Boolean(latestUpdatedAt && latestUpdatedAt >= cutoff); - } - - async function buildIssueGraphLivenessAutoRecoveryPreview( - opts?: { lookbackHours?: number; now?: Date }, - ): Promise { - const now = opts?.now ?? new Date(); - const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours(opts?.lookbackHours); - const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); - const findings = await collectIssueGraphLivenessFindings(); - const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); - const issueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))]; - const recoveryRows = issueIds.length > 0 - ? await db - .select({ id: issues.id, identifier: issues.identifier, title: issues.title }) - .from(issues) - .where(inArray(issues.id, issueIds)) - : []; - const recoveryById = new Map(recoveryRows.map((row) => [row.id, row])); - const items: IssueGraphLivenessAutoRecoveryPreviewItem[] = []; - let skippedOutsideLookback = 0; - - for (const finding of findings) { - const latestDependencyUpdatedAt = latestDependencyUpdatedAtForLivenessFinding( - finding, - updatedAtByIssueKey, - ); - if (!latestDependencyUpdatedAt || latestDependencyUpdatedAt < cutoff) { - skippedOutsideLookback += 1; - continue; - } - const recoveryIssue = recoveryById.get(finding.recoveryIssueId); - items.push({ - issueId: finding.issueId, - identifier: finding.identifier, - title: finding.dependencyPath[0]?.title ?? finding.identifier ?? finding.issueId, - state: finding.state, - severity: finding.severity, - reason: finding.reason, - recoveryIssueId: finding.recoveryIssueId, - recoveryIdentifier: recoveryIssue?.identifier ?? null, - recoveryTitle: recoveryIssue?.title ?? null, - recommendedOwnerAgentId: finding.recommendedOwnerAgentId, - incidentKey: finding.incidentKey, - latestDependencyUpdatedAt: latestDependencyUpdatedAt.toISOString(), - dependencyPath: finding.dependencyPath, - }); - } - - return { - lookbackHours, - cutoff: cutoff.toISOString(), - generatedAt: now.toISOString(), - findings: findings.length, - recoverableFindings: items.length, - skippedOutsideLookback, - items, - }; - } - - async function resolveEscalationOwnerAgentId( - finding: IssueLivenessFinding, - issue: typeof issues.$inferSelect, - ) { - const detailedCandidates = finding.recommendedOwnerCandidates.length > 0 - ? finding.recommendedOwnerCandidates - : finding.recommendedOwnerCandidateAgentIds.map((agentId) => ({ - agentId, - reason: "ordered_invokable_fallback" as const, - sourceIssueId: finding.recoveryIssueId, - })); - const seenCandidates = new Set(); - const candidates = detailedCandidates.filter((candidate) => { - if (seenCandidates.has(candidate.agentId)) return false; - seenCandidates.add(candidate.agentId); - return true; - }); - const budgetBlockedCandidateAgentIds: string[] = []; - - for (const candidate of candidates) { - const budgetBlock = await budgets.getInvocationBlock(issue.companyId, candidate.agentId, { - issueId: issue.id, - projectId: issue.projectId, - }); - if (!budgetBlock) { - return { - agentId: candidate.agentId, - reason: candidate.reason, - sourceIssueId: candidate.sourceIssueId, - candidateAgentIds: candidates.map((entry) => entry.agentId), - candidateReasons: candidates.map((entry) => ({ - agentId: entry.agentId, - reason: entry.reason, - sourceIssueId: entry.sourceIssueId, - })), - budgetBlockedCandidateAgentIds, - }; - } - budgetBlockedCandidateAgentIds.push(candidate.agentId); - } - - return null; - } - - function shouldReuseRecoveryExecutionWorkspace(input: { - finding: IssueLivenessFinding; - recoveryIssue: typeof issues.$inferSelect; - ownerAgentId: string; - }) { - if (input.finding.recoveryIssueId === input.finding.issueId) return false; - return input.recoveryIssue.assigneeAgentId === input.ownerAgentId; - } - - async function ensureIssueBlockedByEscalation(input: { - issue: typeof issues.$inferSelect; - escalationIssueId: string; - finding: IssueLivenessFinding; - runId?: string | null; - }) { - const blockerIds = await existingBlockerIssueIds(input.issue.companyId, input.issue.id); - const nextBlockerIds = [...new Set([...blockerIds, input.escalationIssueId])]; - const isAlreadyBlockedByEscalation = blockerIds.includes(input.escalationIssueId); - const isAlreadyBlocked = input.issue.status === "blocked"; - if (isAlreadyBlockedByEscalation && isAlreadyBlocked) { - return input.issue; - } - - const update: Partial & { blockedByIssueIds: string[] } = { - blockedByIssueIds: nextBlockerIds, - }; - if (!isAlreadyBlocked) { - update.status = "blocked"; - } - - const updated = await issuesSvc.update(input.issue.id, update); - if (!updated) return null; - - await logActivity(db, { - companyId: input.issue.companyId, - actorType: "system", - actorId: "system", - agentId: null, - runId: input.runId ?? null, - action: "issue.blockers.updated", - entityType: "issue", - entityId: input.issue.id, - details: { - source: "recovery.reconcile_issue_graph_liveness", - incidentKey: input.finding.incidentKey, - findingState: input.finding.state, - blockerIssueIds: nextBlockerIds, - escalationIssueId: input.escalationIssueId, - status: update.status ?? input.issue.status, - previousStatus: input.issue.status, - }, - }); - - return updated; - } - - async function createIssueGraphLivenessEscalation(input: { - finding: IssueLivenessFinding; - runId?: string | null; - now: Date; - reescalationCooldownMs: number; - }) { - const issue = await db - .select() - .from(issues) - .where(eq(issues.id, input.finding.issueId)) - .then((rows) => rows[0] ?? null); - if (!issue || issue.companyId !== input.finding.companyId) return { kind: "skipped" as const }; - if (await isAutomaticRecoverySuppressedByPauseHold(db, issue.companyId, issue.id, treeControlSvc)) { - return { kind: "skipped" as const }; - } - - const recoveryIssue = await db - .select() - .from(issues) - .where(and(eq(issues.id, input.finding.recoveryIssueId), eq(issues.companyId, issue.companyId))) - .then((rows) => rows[0] ?? null); - if (!recoveryIssue) return { kind: "skipped" as const }; - - const existing = - await findOpenLivenessEscalation(issue.companyId, input.finding.incidentKey) ?? - await findOpenLivenessRecoveryIssueForLeaf(input.finding); - if (existing) { - await ensureIssueBlockedByEscalation({ - issue, - escalationIssueId: existing.id, - finding: input.finding, - runId: input.runId ?? null, - }); - return { kind: "existing" as const, escalationIssueId: existing.id }; - } - if (await findRecentCompletedLivenessRecoveryIssue( - input.finding, - input.now, - input.reescalationCooldownMs, - )) { - return { kind: "cooldown" as const }; - } - - const ownerSelection = await resolveEscalationOwnerAgentId(input.finding, recoveryIssue); - if (!ownerSelection) return { kind: "skipped" as const }; - const reuseRecoveryExecutionWorkspace = shouldReuseRecoveryExecutionWorkspace({ - finding: input.finding, - recoveryIssue, - ownerAgentId: ownerSelection.agentId, - }); - - let escalation: Awaited>; - try { - escalation = await issuesSvc.create(issue.companyId, { - title: `Unblock liveness incident for ${issue.identifier ?? issue.id}`, - description: buildLivenessEscalationDescription(input.finding), - status: "todo", - priority: "high", - parentId: recoveryIssue.id, - projectId: recoveryIssue.projectId, - goalId: recoveryIssue.goalId, - assigneeAgentId: ownerSelection.agentId, - assigneeAdapterOverrides: recoveryAssigneeAdapterOverrides("status_only"), - originKind: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation, - originId: input.finding.incidentKey, - originFingerprint: livenessRecoveryLeafFingerprint(input.finding), - billingCode: recoveryIssue.billingCode, - ...(reuseRecoveryExecutionWorkspace - ? { inheritExecutionWorkspaceFromIssueId: recoveryIssue.id } - : { - executionWorkspaceId: null, - executionWorkspacePreference: null, - executionWorkspaceSettings: null, - }), - }); - } catch (error) { - if (!isUniqueLivenessRecoveryConflict(error)) throw error; - const raced = - await findOpenLivenessEscalation(issue.companyId, input.finding.incidentKey) ?? - await findOpenLivenessRecoveryIssueForLeaf(input.finding); - if (!raced) throw error; - await ensureIssueBlockedByEscalation({ - issue, - escalationIssueId: raced.id, - finding: input.finding, - runId: input.runId ?? null, - }); - return { kind: "existing" as const, escalationIssueId: raced.id }; - } - - await ensureIssueBlockedByEscalation({ - issue, - escalationIssueId: escalation.id, - finding: input.finding, - runId: input.runId ?? null, - }); - - await issuesSvc.addComment( - issue.id, - buildLivenessOriginalIssueComment(input.finding, escalation), - { runId: input.runId ?? null }, - ); - - await logActivity(db, { - companyId: issue.companyId, - actorType: "system", - actorId: "system", - agentId: ownerSelection.agentId, - runId: input.runId ?? null, - action: "issue.harness_liveness_escalation_created", - entityType: "issue", - entityId: escalation.id, - details: { - source: "recovery.reconcile_issue_graph_liveness", - incidentKey: input.finding.incidentKey, - findingState: input.finding.state, - sourceIssueId: issue.id, - sourceIdentifier: issue.identifier, - recoveryIssueId: recoveryIssue.id, - recoveryIdentifier: recoveryIssue.identifier, - escalationIssueId: escalation.id, - escalationIdentifier: escalation.identifier, - dependencyPath: input.finding.dependencyPath, - ownerSelection: { - selectedAgentId: ownerSelection.agentId, - selectedReason: ownerSelection.reason, - selectedSourceIssueId: ownerSelection.sourceIssueId, - candidateAgentIds: ownerSelection.candidateAgentIds, - candidateReasons: ownerSelection.candidateReasons, - budgetBlockedCandidateAgentIds: ownerSelection.budgetBlockedCandidateAgentIds, - }, - workspaceSelection: { - reuseRecoveryExecutionWorkspace, - inheritedExecutionWorkspaceFromIssueId: reuseRecoveryExecutionWorkspace ? recoveryIssue.id : null, - projectWorkspaceSourceIssueId: recoveryIssue.id, - }, - }, - }); - - const wake = await deps.enqueueWakeup(ownerSelection.agentId, { - source: "assignment", - triggerDetail: "system", - reason: "issue_assigned", - payload: withRecoveryModelProfileHint({ - issueId: escalation.id, - sourceIssueId: issue.id, - recoveryIssueId: recoveryIssue.id, - incidentKey: input.finding.incidentKey, - }, "status_only"), - requestedByActorType: "system", - requestedByActorId: null, - contextSnapshot: withRecoveryModelProfileHint({ - issueId: escalation.id, - taskId: escalation.id, - wakeReason: "issue_assigned", - source: RECOVERY_ORIGIN_KINDS.issueGraphLivenessEscalation, - sourceIssueId: issue.id, - recoveryIssueId: recoveryIssue.id, - incidentKey: input.finding.incidentKey, - }, "status_only"), - }); - - logger.warn({ - incidentKey: input.finding.incidentKey, - findingState: input.finding.state, - sourceIssueId: issue.id, - recoveryIssueId: recoveryIssue.id, - escalationIssueId: escalation.id, - ownerAgentId: ownerSelection.agentId, - ownerSelectionReason: ownerSelection.reason, - wakeupRunId: wake?.id ?? null, - }, "created issue graph liveness escalation"); - - return { kind: "created" as const, escalationIssueId: escalation.id }; - } - async function reconcileResolvedDependencyWakeBackstop(opts?: ResolvedDependencyWakeBackstopOptions) { const result = { checked: 0, @@ -5415,130 +4482,6 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) return result; } - async function reconcileIssueGraphLiveness(opts?: { - runId?: string | null; - force?: boolean; - lookbackHours?: number; - issueCreatedAtGte?: Date | null; - now?: Date; - reescalationCooldownMs?: number; - }) { - let findings = await collectIssueGraphLivenessFindings(); - if (opts?.issueCreatedAtGte) { - const findingIssueIds = [...new Set(findings.map((finding) => finding.recoveryIssueId))]; - const eligibleIssueIds = new Set( - findingIssueIds.length === 0 - ? [] - : (await db - .select({ id: issues.id }) - .from(issues) - .where(and( - inArray(issues.id, findingIssueIds), - gte(issues.createdAt, opts.issueCreatedAtGte), - ))) - .map((issue) => issue.id), - ); - findings = findings.filter((finding) => eligibleIssueIds.has(finding.recoveryIssueId)); - } - const experimentalSettings = await instanceSettings.getExperimental(); - const autoRecoveryEnabled = asBoolean( - experimentalSettings.enableIssueGraphLivenessAutoRecovery, - true, - ) || opts?.force === true; - const lookbackHours = normalizeIssueGraphLivenessAutoRecoveryLookbackHours( - opts?.lookbackHours ?? experimentalSettings.issueGraphLivenessAutoRecoveryLookbackHours, - ); - const now = opts?.now ?? new Date(); - const reescalationCooldownMs = Math.max( - 0, - Math.floor(asNumber(opts?.reescalationCooldownMs, DEFAULT_LIVENESS_REESCALATION_COOLDOWN_MS)), - ); - const cutoff = new Date(now.getTime() - lookbackHours * 60 * 60 * 1000); - const obsoleteRecoveryCleanup = await retireObsoleteLivenessRecoveryIssues(findings); - const doneRecoveryBlockerCleanup = await retireDoneLivenessRecoveryBlockers(); - const updatedAtByIssueKey = await loadLivenessDependencyUpdatedAtByIssue(findings); - const result = { - findings: findings.length, - autoRecoveryEnabled, - lookbackHours, - cutoff: cutoff.toISOString(), - escalationsCreated: 0, - existingEscalations: 0, - skipped: 0, - skippedAutoRecoveryDisabled: 0, - skippedOutsideLookback: 0, - skippedReescalationCooldown: 0, - obsoleteRecoveriesRetired: obsoleteRecoveryCleanup.retired, - obsoleteRecoveriesActiveSkipped: obsoleteRecoveryCleanup.activeSkipped, - obsoleteRecoveryBlockerRelationsRemoved: obsoleteRecoveryCleanup.blockerRelationsRemoved, - doneRecoveryBlockerRelationsRemoved: doneRecoveryBlockerCleanup.blockerRelationsRemoved, - dependencyWakeBackstopChecked: 0, - dependencyWakesHealed: 0, - dependencyWakeExistingSkipped: 0, - dependencyWakeLivePathSkipped: 0, - dependencyWakeInteractionSkipped: 0, - dependencyWakePauseHoldSkipped: 0, - dependencyWakeNotReadySkipped: 0, - dependencyWakeCandidateLimitSkipped: 0, - dependencyWakeDeferredOrFailed: 0, - dependencyWakeEnqueueFailed: 0, - dependencyWakeIssueIds: [] as string[], - issueIds: [] as string[], - escalationIssueIds: [] as string[], - retiredRecoveryIssueIds: obsoleteRecoveryCleanup.retiredIssueIds, - }; - - const dependencyWakeBackstop = await reconcileResolvedDependencyWakeBackstop({ - runId: opts?.runId ?? null, - }); - result.dependencyWakeBackstopChecked = dependencyWakeBackstop.checked; - result.dependencyWakesHealed = dependencyWakeBackstop.healed; - result.dependencyWakeExistingSkipped = dependencyWakeBackstop.existingWakeSkipped; - result.dependencyWakeLivePathSkipped = dependencyWakeBackstop.livePathSkipped; - result.dependencyWakeInteractionSkipped = dependencyWakeBackstop.interactionSkipped; - result.dependencyWakePauseHoldSkipped = dependencyWakeBackstop.pauseHoldSkipped; - result.dependencyWakeNotReadySkipped = dependencyWakeBackstop.notReadySkipped; - result.dependencyWakeCandidateLimitSkipped = dependencyWakeBackstop.candidateLimitSkipped; - result.dependencyWakeDeferredOrFailed = dependencyWakeBackstop.deferredOrFailed; - result.dependencyWakeEnqueueFailed = dependencyWakeBackstop.enqueueFailed; - result.dependencyWakeIssueIds = dependencyWakeBackstop.issueIds; - - if (!autoRecoveryEnabled) { - result.skippedAutoRecoveryDisabled = findings.length; - return result; - } - - for (const finding of findings) { - if (!isLivenessFindingInsideAutoRecoveryLookback(finding, cutoff, updatedAtByIssueKey)) { - result.skippedOutsideLookback += 1; - result.skipped += 1; - continue; - } - const escalation = await createIssueGraphLivenessEscalation({ - finding, - runId: opts?.runId ?? null, - now, - reescalationCooldownMs, - }); - if (escalation.kind === "created") { - result.escalationsCreated += 1; - result.issueIds.push(finding.issueId); - result.escalationIssueIds.push(escalation.escalationIssueId); - } else if (escalation.kind === "existing") { - result.existingEscalations += 1; - result.issueIds.push(finding.issueId); - result.escalationIssueIds.push(escalation.escalationIssueId); - } else if (escalation.kind === "cooldown") { - result.skippedReescalationCooldown += 1; - result.skipped += 1; - } else { - result.skipped += 1; - } - } - - return result; - } - function readRecoveryTimerIntervalMs(raw: unknown, fallback: number) { return Math.max(1, Math.floor(asNumber(raw, fallback))); } @@ -5894,9 +4837,7 @@ export function recoveryService(db: Db, deps: { enqueueWakeup: RecoveryWakeup }) scanSilentActiveRuns, reconcileStrandedAssignedIssues, sweepStaleIssueLocks, - buildIssueGraphLivenessAutoRecoveryPreview, reconcileResolvedDependencyWakeBackstop, - reconcileIssueGraphLiveness, readRecoveryTimerIntervalMs, }; } diff --git a/ui/src/App.activity-routing.test.tsx b/ui/src/App.activity-routing.test.tsx index 55eb7abb30..88c1e3c8c7 100644 --- a/ui/src/App.activity-routing.test.tsx +++ b/ui/src/App.activity-routing.test.tsx @@ -69,6 +69,13 @@ vi.mock("./pages/audit/CompanyActivity", () => ({ }, })); +vi.mock("./pages/Issues", () => ({ + Issues: () => { + const location = useLocation(); + return
{`TASKS_PAGE@${location.pathname}`}
; + }, +})); + const PAP_COMPANY = { id: "company-1", name: "Paperclip", @@ -172,4 +179,11 @@ describe("App Activity routing (PAP-16302)", () => { expect(container.textContent).not.toContain("No organization matches prefix"); flushSync(() => root.unmount()); }); + + it("redirects the bare /tasks post-login target to the real task list", async () => { + const root = renderAppAt(container, "/tasks"); + await waitForRoute(container, "TASKS_PAGE@/PAP/issues"); + expect(container.textContent).not.toContain("/tasks/dashboard"); + flushSync(() => root.unmount()); + }); }); diff --git a/ui/src/App.tsx b/ui/src/App.tsx index bc88dd5211..5f94bba5e8 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -13,6 +13,10 @@ import { AppsExperimentalGate } from "./components/AppsExperimentalGate"; import { CloudManagedPageGate } from "./components/CloudManagedPageGate"; import { HiddenSettingsPageGate } from "./components/HiddenSettingsPageGate"; import { IsolatedWorkspacesRouteGate } from "./components/IsolatedWorkspacesRouteGate"; +import { + ExecutionWorkspaceCompanyGate, + UnprefixedExecutionWorkspaceRedirect, +} from "./components/UnprefixedExecutionWorkspaceRedirect"; import { useHiddenSettings } from "./hooks/useHiddenSettings"; import { Cases } from "./pages/Cases"; import { CaseDetail } from "./pages/CaseDetail"; @@ -239,6 +243,7 @@ function boardRoutes() { } /> } /> + } /> } /> } /> } /> @@ -304,12 +309,14 @@ function boardRoutes() { } /> } /> }> - } /> - } /> - } /> - } /> - } /> - } /> + }> + } /> + } /> + } /> + } /> + } /> + } /> + } /> } /> @@ -664,6 +671,7 @@ export function App() { } /> } /> } /> + } /> } /> } /> } /> @@ -709,12 +717,12 @@ export function App() { } /> } /> } /> - } /> - } /> - } /> - } /> - } /> - } /> + } /> + } /> + } /> + } /> + } /> + } /> }> {boardRoutes()} diff --git a/ui/src/api/instanceSettings.ts b/ui/src/api/instanceSettings.ts index 8bf1454b0a..c6d69f1ae9 100644 --- a/ui/src/api/instanceSettings.ts +++ b/ui/src/api/instanceSettings.ts @@ -2,7 +2,6 @@ import type { InstanceExperimentalSettingsWithManaged, InstanceGeneralSettings, InstanceSettings, - IssueGraphLivenessAutoRecoveryPreview, PatchInstanceSettings, PatchInstanceGeneralSettings, PatchInstanceExperimentalSettings, @@ -22,36 +21,4 @@ export const instanceSettingsApi = { api.get("/instance/settings/experimental"), updateExperimental: (patch: PatchInstanceExperimentalSettings) => api.patch("/instance/settings/experimental", patch), - previewIssueGraphLivenessAutoRecovery: (input: { lookbackHours?: number }) => - api.post( - "/instance/settings/experimental/issue-graph-liveness-auto-recovery/preview", - input, - ), - runIssueGraphLivenessAutoRecovery: (input: { lookbackHours?: number }) => - api.post<{ - findings: number; - autoRecoveryEnabled: boolean; - lookbackHours: number; - cutoff: string; - escalationsCreated: number; - existingEscalations: number; - skipped: number; - skippedAutoRecoveryDisabled: number; - skippedOutsideLookback: number; - dependencyWakeBackstopChecked: number; - dependencyWakesHealed: number; - dependencyWakeExistingSkipped: number; - dependencyWakeLivePathSkipped: number; - dependencyWakeInteractionSkipped: number; - dependencyWakePauseHoldSkipped: number; - dependencyWakeNotReadySkipped: number; - dependencyWakeCandidateLimitSkipped: number; - dependencyWakeDeferredOrFailed: number; - dependencyWakeEnqueueFailed: number; - dependencyWakeIssueIds: string[]; - escalationIssueIds: string[]; - }>( - "/instance/settings/experimental/issue-graph-liveness-auto-recovery/run", - input, - ), }; diff --git a/ui/src/components/IssueProperties.test.tsx b/ui/src/components/IssueProperties.test.tsx index bacdee29f3..f63581e9ff 100644 --- a/ui/src/components/IssueProperties.test.tsx +++ b/ui/src/components/IssueProperties.test.tsx @@ -497,7 +497,6 @@ describe("IssueProperties", () => { ], }); mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: false, }); }); @@ -507,7 +506,6 @@ describe("IssueProperties", () => { it("does not show a Plan tab for a planning-mode issue without a plan document", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: false, enableClassicTaskInterface: false, }); const root = renderProperties(container, { @@ -555,7 +553,6 @@ describe("IssueProperties", () => { latestRevisionId: "revision-evidence", } satisfies IssueDocument; mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: false, enableClassicTaskInterface: false, }); mockIssuesApi.getDocument.mockResolvedValue(planDocument); @@ -859,7 +856,6 @@ describe("IssueProperties", () => { // The chat shell hosts the full tree in the center pane; the slim pill row // + its Add sub-task button only render in the classic layout (PAP-496). mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: false, enableClassicTaskInterface: true, }); const onAddSubIssue = vi.fn(); @@ -904,24 +900,7 @@ describe("IssueProperties", () => { act(() => root.unmount()); }); - it("hides watchdog setup controls while the experimental flag is off", async () => { - const root = renderProperties(container, { - issue: createIssue(), - childIssues: [], - onUpdate: vi.fn(), - }); - await flush(); - - expect(container.textContent).not.toContain("Watchdog"); - expect(container.textContent).not.toContain("Set watchdog"); - - act(() => root.unmount()); - }); - - it("shows watchdog setup controls when the experimental flag is enabled", async () => { - mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: true, - }); + it("always shows watchdog setup controls", async () => { const root = renderProperties(container, { issue: createIssue(), childIssues: [], @@ -1206,7 +1185,6 @@ describe("IssueProperties", () => { // The sub-task pill row (with its collapse control) is classic-layout only // now — the chat shell promotes sub-tasks to their own pane tab (PAP-496). mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: false, enableClassicTaskInterface: true, }); const blockedBy = Array.from({ length: 7 }, (_, index) => ({ @@ -2517,7 +2495,6 @@ describe("IssueProperties", () => { it("shows the empty watchdog state and saves a new watchdog via the API", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: true, }); mockAgentsApi.list.mockResolvedValue([watchdogAgent]); const onUpdate = vi.fn(); @@ -2584,7 +2561,6 @@ describe("IssueProperties", () => { it("updates cached issue detail when saving a watchdog", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: true, }); mockAgentsApi.list.mockResolvedValue([watchdogAgent]); const savedWatchdog = createWatchdogSummary({ @@ -2639,7 +2615,6 @@ describe("IssueProperties", () => { it("renders an existing watchdog and removes it via the API", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: true, }); mockAgentsApi.list.mockResolvedValue([watchdogAgent]); const onUpdate = vi.fn(); @@ -2681,7 +2656,6 @@ describe("IssueProperties", () => { it("truncates the watchdog instructions one-line summary in the properties value column", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: true, }); mockAgentsApi.list.mockResolvedValue([watchdogAgent]); const instructions = "get greptile to stop re-reviewing the same task unless a fresh code change lands"; @@ -2720,7 +2694,6 @@ describe("IssueProperties", () => { it("links to the generated watchdog task when one exists", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ - enableTaskWatchdogs: true, }); mockAgentsApi.list.mockResolvedValue([watchdogAgent]); const root = renderProperties(container, { diff --git a/ui/src/components/NewIssueDialog.test.tsx b/ui/src/components/NewIssueDialog.test.tsx index fe68c3cfa1..4e0004a753 100644 --- a/ui/src/components/NewIssueDialog.test.tsx +++ b/ui/src/components/NewIssueDialog.test.tsx @@ -1351,7 +1351,6 @@ describe("NewIssueDialog", () => { it("reveals the watchdog editor from the overflow menu", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, - enableTaskWatchdogs: true, }); const { root } = renderDialog(container); @@ -1378,7 +1377,6 @@ describe("NewIssueDialog", () => { it("submits the configured watchdog from a restored draft", async () => { mockInstanceSettingsApi.getExperimental.mockResolvedValue({ enableIsolatedWorkspaces: false, - enableTaskWatchdogs: true, }); localStorage.setItem( "paperclip:issue-draft", diff --git a/ui/src/components/NewIssueDialog.tsx b/ui/src/components/NewIssueDialog.tsx index a7e514a4d8..21f23d5193 100644 --- a/ui/src/components/NewIssueDialog.tsx +++ b/ui/src/components/NewIssueDialog.tsx @@ -1096,7 +1096,7 @@ export function NewIssueDialog() { : {}), ...(executionWorkspaceSettings ? { executionWorkspaceSettings } : {}), ...(executionPolicy ? { executionPolicy } : {}), - ...(taskWatchdogsEnabled && watchdogAgentId + ...(watchdogAgentId ? { watchdog: { agentId: watchdogAgentId, instructions: watchdogInstructions.trim() || null } } : {}), }); @@ -1200,7 +1200,6 @@ export function NewIssueDialog() { ? currentProject?.executionWorkspacePolicy ?? null : null; const currentProjectSupportsExecutionWorkspace = Boolean(currentProjectExecutionWorkspacePolicy?.enabled); - const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true; const selectableReusableWorkspaces = reusableExecutionWorkspaces ?? []; const selectedReusableExecutionWorkspace = selectableReusableWorkspaces.find( (workspace) => workspace.id === selectedExecutionWorkspaceId, @@ -1606,7 +1605,7 @@ export function NewIssueDialog() { @@ -1640,29 +1639,27 @@ export function NewIssueDialog() { Approver - {taskWatchdogsEnabled && ( - - )} + @@ -1759,7 +1756,7 @@ export function NewIssueDialog() { )} {/* Watchdog row */} - {taskWatchdogsEnabled && showWatchdogRow && ( + {showWatchdogRow && (
diff --git a/ui/src/components/UnprefixedExecutionWorkspaceRedirect.test.tsx b/ui/src/components/UnprefixedExecutionWorkspaceRedirect.test.tsx new file mode 100644 index 0000000000..9584e839bd --- /dev/null +++ b/ui/src/components/UnprefixedExecutionWorkspaceRedirect.test.tsx @@ -0,0 +1,115 @@ +// @vitest-environment jsdom + +import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; +import { flushSync } from "react-dom"; +import { createRoot, type Root } from "react-dom/client"; +import { MemoryRouter, Route, Routes, useLocation } from "react-router-dom"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import { + ExecutionWorkspaceCompanyGate, + UnprefixedExecutionWorkspaceRedirect, +} from "./UnprefixedExecutionWorkspaceRedirect"; + +const mockExecutionWorkspacesApi = vi.hoisted(() => ({ get: vi.fn() })); +vi.mock("@/api/execution-workspaces", () => ({ + executionWorkspacesApi: mockExecutionWorkspacesApi, +})); + +const PAP = { id: "company-pap", name: "Paperclip", issuePrefix: "PAP", status: "active" }; +const FOR = { id: "company-for", name: "Forgotten Runes", issuePrefix: "FOR", status: "active" }; +vi.mock("@/context/CompanyContext", () => ({ + useCompany: () => ({ + companies: [PAP, FOR], + selectedCompanyId: FOR.id, + selectedCompany: FOR, + loading: false, + }), +})); + +vi.mock("../pages/NotFound", () => ({ + NotFoundPage: () =>
NOT_FOUND
, +})); + +function Destination() { + const location = useLocation(); + return
{`DESTINATION@${location.pathname}${location.search}${location.hash}`}
; +} + +describe("UnprefixedExecutionWorkspaceRedirect", () => { + let container: HTMLDivElement; + let root: Root; + + beforeEach(() => { + container = document.createElement("div"); + document.body.appendChild(container); + }); + + afterEach(() => { + flushSync(() => root?.unmount()); + container.remove(); + vi.clearAllMocks(); + }); + + function render(path: string) { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root.render( + + + + } /> + } /> + + + , + ); + }); + } + + function renderGate(path: string) { + root = createRoot(container); + const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } }); + flushSync(() => { + root.render( + + + + }> + } /> + + + + , + ); + }); + } + + it("uses the workspace owner instead of the selected company after login", async () => { + mockExecutionWorkspacesApi.get.mockResolvedValue({ id: "workspace-1", companyId: PAP.id }); + render("/execution-workspaces/workspace-1/issues?tab=open#latest"); + + await vi.waitFor(() => { + expect(container.textContent).toContain( + "DESTINATION@/PAP/execution-workspaces/workspace-1/issues?tab=open#latest", + ); + }); + expect(container.textContent).not.toContain("/FOR/execution-workspaces"); + }); + + it("shows not found when the workspace cannot be resolved", async () => { + mockExecutionWorkspacesApi.get.mockRejectedValue(new Error("Execution workspace not found")); + render("/execution-workspaces/missing/issues"); + + await vi.waitFor(() => expect(container.textContent).toContain("NOT_FOUND")); + expect(container.textContent).not.toContain("DESTINATION@"); + }); + + it("rejects a prefixed route for a different company's workspace", async () => { + mockExecutionWorkspacesApi.get.mockResolvedValue({ id: "workspace-1", companyId: PAP.id }); + renderGate("/FOR/execution-workspaces/workspace-1/issues"); + + await vi.waitFor(() => expect(container.textContent).toContain("NOT_FOUND")); + expect(container.textContent).not.toContain("DESTINATION@"); + }); +}); diff --git a/ui/src/components/UnprefixedExecutionWorkspaceRedirect.tsx b/ui/src/components/UnprefixedExecutionWorkspaceRedirect.tsx new file mode 100644 index 0000000000..4f5f81af84 --- /dev/null +++ b/ui/src/components/UnprefixedExecutionWorkspaceRedirect.tsx @@ -0,0 +1,64 @@ +import { useQuery } from "@tanstack/react-query"; +import { executionWorkspacesApi } from "@/api/execution-workspaces"; +import { useCompany } from "@/context/CompanyContext"; +import { queryKeys } from "@/lib/queryKeys"; +import { Navigate, Outlet, useLocation, useParams } from "@/lib/router"; +import { PaperclipLoading } from "./AnimatedPaperclipIcon"; +import { NotFoundPage } from "../pages/NotFound"; + +/** Resolve a prefix-free workspace URL from the resource, not browsing state. */ +export function UnprefixedExecutionWorkspaceRedirect() { + const location = useLocation(); + const { workspaceId } = useParams<{ workspaceId?: string }>(); + const { companies, loading: companiesLoading } = useCompany(); + const workspaceQuery = useQuery({ + queryKey: queryKeys.executionWorkspaces.detail(workspaceId ?? "__missing__"), + queryFn: () => executionWorkspacesApi.get(workspaceId!), + enabled: Boolean(workspaceId), + retry: false, + }); + + if (!workspaceId) return ; + if (companiesLoading || workspaceQuery.isPending) return ; + if (workspaceQuery.isError) return ; + + const targetCompany = companies.find( + (company) => company.id === workspaceQuery.data.companyId, + ); + if (!targetCompany) return ; + + return ( + + ); +} + +/** Reject a prefixed URL when its prefix and workspace belong to different companies. */ +export function ExecutionWorkspaceCompanyGate() { + const { companyPrefix, workspaceId } = useParams<{ + companyPrefix?: string; + workspaceId?: string; + }>(); + const { companies, loading: companiesLoading } = useCompany(); + const workspaceQuery = useQuery({ + queryKey: queryKeys.executionWorkspaces.detail(workspaceId ?? "__missing__"), + queryFn: () => executionWorkspacesApi.get(workspaceId!), + enabled: Boolean(workspaceId), + retry: false, + }); + + if (!workspaceId || !companyPrefix) return ; + if (companiesLoading || workspaceQuery.isPending) return ; + if (workspaceQuery.isError) return ; + + const routeCompany = companies.find( + (company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(), + ); + if (!routeCompany || routeCompany.id !== workspaceQuery.data.companyId) { + return ; + } + + return ; +} diff --git a/ui/src/components/WorkspaceAccessCard.test.tsx b/ui/src/components/WorkspaceAccessCard.test.tsx index 9c93bca108..f822d3e655 100644 --- a/ui/src/components/WorkspaceAccessCard.test.tsx +++ b/ui/src/components/WorkspaceAccessCard.test.tsx @@ -168,6 +168,23 @@ describe("WorkspaceAccessCard", () => { act(() => root.unmount()); }); + it("does not show a provisioning badge or spinner when the workspace can be started", () => { + const { root } = renderCard({ + access: accessState({ + state: "stopped", + title: "Workspace is not running", + description: "Start the workspace runtime to publish its board.", + action: { kind: "start", label: "Start workspace" }, + }), + }); + + const badge = container.querySelector("[data-testid='workspace-access-badge']"); + expect(badge).toBeNull(); + expect(container.querySelector(".animate-spin")).toBeNull(); + expect(findButton("Start workspace")).toBeDefined(); + act(() => root.unmount()); + }); + it("always names the state and the cause instead of a bare failure", () => { const { root } = renderCard({ access: accessState({ diff --git a/ui/src/components/WorkspaceAccessCard.tsx b/ui/src/components/WorkspaceAccessCard.tsx index 6373d4a69c..95c2a9002d 100644 --- a/ui/src/components/WorkspaceAccessCard.tsx +++ b/ui/src/components/WorkspaceAccessCard.tsx @@ -19,7 +19,9 @@ import type { WorkspaceAccessState } from "../lib/workspace-access-state"; * and `degraded` the "todo" (amber) tone; the icon variants are the contrast- * corrected pair the token layer already tunes per mode. */ -const STATE_BADGE_CLASSES: Record = { +type ActiveWorkspaceAccessState = Exclude; + +const STATE_BADGE_CLASSES: Record = { provisioning: "border-border text-muted-foreground", validating: "border-border text-muted-foreground", ready: "border-(--status-task-done) text-(--status-task-icon-done)", @@ -28,7 +30,7 @@ const STATE_BADGE_CLASSES: Record = { failed: "border-destructive/50 text-destructive", }; -const STATE_LABELS: Record = { +const STATE_LABELS: Record = { provisioning: "Provisioning", validating: "Validating clone", ready: "Ready", @@ -80,18 +82,20 @@ export function WorkspaceAccessCard({
{access.title} - - {(access.state === "repairing" || access.state === "provisioning") && ( - - )} - {STATE_LABELS[access.state]} - + {access.state !== "stopped" ? ( + + {(access.state === "repairing" || access.state === "provisioning") && ( + + )} + {STATE_LABELS[access.state]} + + ) : null}
{access.description}
diff --git a/ui/src/components/issue-properties/IssueProperties.tsx b/ui/src/components/issue-properties/IssueProperties.tsx index 229bda51c9..1e34756d09 100644 --- a/ui/src/components/issue-properties/IssueProperties.tsx +++ b/ui/src/components/issue-properties/IssueProperties.tsx @@ -191,7 +191,6 @@ export function IssueProperties({ queryKey: queryKeys.instance.experimentalSettings, queryFn: () => instanceSettingsApi.getExperimental(), }); - const taskWatchdogsEnabled = experimentalSettings?.enableTaskWatchdogs === true; // Managed-sandbox-only policy: the workspace folder is a host filesystem // path, so the Folder row disappears. The Branch row above it stays. The gate // fails closed whenever the policy is unknown — in flight and also on a failed @@ -2419,32 +2418,30 @@ export function IssueProperties({ {monitorContent} - {taskWatchdogsEnabled ? ( - e.stopPropagation()} - > - - - ) : undefined - } - > - {watchdogContent} - - ) : null} + e.stopPropagation()} + > + + + ) : undefined + } + > + {watchdogContent} + {hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? ( diff --git a/ui/src/lib/company-routes.test.ts b/ui/src/lib/company-routes.test.ts index 78b88de7e2..f478aaf593 100644 --- a/ui/src/lib/company-routes.test.ts +++ b/ui/src/lib/company-routes.test.ts @@ -7,6 +7,12 @@ import { } from "./company-routes"; describe("company routes", () => { + it("treats the task-list alias as an unprefixed board route", () => { + expect(isBoardPathWithoutPrefix("/tasks")).toBe(true); + expect(extractCompanyPrefixFromPath("/tasks")).toBeNull(); + expect(applyCompanyPrefix("/tasks", "PAP")).toBe("/PAP/tasks"); + }); + it("treats execution workspace paths as board routes that need a company prefix", () => { expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123")).toBe(true); expect(isBoardPathWithoutPrefix("/execution-workspaces/workspace-123/routines")).toBe(true); diff --git a/ui/src/lib/company-routes.ts b/ui/src/lib/company-routes.ts index 588f444fc6..498784fa98 100644 --- a/ui/src/lib/company-routes.ts +++ b/ui/src/lib/company-routes.ts @@ -11,6 +11,7 @@ const BOARD_ROUTE_ROOTS = new Set([ "workspaces", "execution-workspaces", "issues", + "tasks", "routines", "goals", "artifacts", diff --git a/ui/src/lib/workspace-access-state.test.ts b/ui/src/lib/workspace-access-state.test.ts index bc48c39ae4..7996a59d94 100644 --- a/ui/src/lib/workspace-access-state.test.ts +++ b/ui/src/lib/workspace-access-state.test.ts @@ -253,7 +253,7 @@ describe("resolveWorkspaceAccessState", () => { }); expect(access).toMatchObject({ - state: "provisioning", + state: "stopped", action: { kind: "start", label: "Start workspace" }, }); }); @@ -344,8 +344,11 @@ describe("resolveWorkspaceAccessState", () => { it("offers start when nothing is running", () => { expect( - resolveWorkspaceAccessState({ runtimeServices: [], operations: [] }), - ).toMatchObject({ state: "provisioning", action: { kind: "start", label: "Start workspace" } }); + resolveWorkspaceAccessState({ + runtimeServices: [runtimeService({ status: "stopped", healthStatus: "unknown", url: null })], + operations: [], + }), + ).toMatchObject({ state: "stopped", action: { kind: "start", label: "Start workspace" } }); expect( resolveWorkspaceAccessState({ @@ -353,7 +356,20 @@ describe("resolveWorkspaceAccessState", () => { operations: [], handoffFailure: { reason: "runtime_not_running" }, }), - ).toMatchObject({ state: "provisioning", action: { kind: "start" } }); + ).toMatchObject({ state: "stopped", action: { kind: "start" } }); + }); + + it("shows provisioning only while a runtime service is actually starting", () => { + expect( + resolveWorkspaceAccessState({ + runtimeServices: [runtimeService({ status: "starting", healthStatus: "unknown", url: null })], + operations: [], + handoffFailure: { reason: "runtime_not_running" }, + }), + ).toMatchObject({ + state: "provisioning", + action: { kind: "wait", label: "Starting workspace" }, + }); }); it("refuses to call an unhealthy running runtime ready", () => { @@ -374,7 +390,7 @@ describe("resolveWorkspaceAccessState", () => { it("handles missing operations and services without throwing", () => { expect(resolveWorkspaceAccessState({ runtimeServices: null, operations: undefined }).state) - .toBe("provisioning"); + .toBe("stopped"); }); }); diff --git a/ui/src/lib/workspace-access-state.ts b/ui/src/lib/workspace-access-state.ts index 7326338425..e002533130 100644 --- a/ui/src/lib/workspace-access-state.ts +++ b/ui/src/lib/workspace-access-state.ts @@ -37,8 +37,10 @@ export type WorkspaceAccessNotice = { action: WorkspaceAccessAction; }; +export type WorkspaceAccessDisplayState = WorkspaceReadinessState | "stopped"; + export type WorkspaceAccessState = { - state: WorkspaceReadinessState; + state: WorkspaceAccessDisplayState; title: string; description: string; action: WorkspaceAccessAction; @@ -147,6 +149,9 @@ export function resolveWorkspaceAccessState(input: { const servingService = runtimeServices.find( (service) => service.status === "running" && service.healthStatus === "healthy" && service.url, ); + const startingService = runtimeServices.find( + (service) => service.status === "provisioning" || service.status === "starting", + ); const repairFinishedAt = timestampMs(repair?.finishedAt); const servingServiceStartedAt = timestampMs(servingService?.startedAt); const provisionFinishedAt = timestampMs(provision?.finishedAt); @@ -227,9 +232,9 @@ export function resolveWorkspaceAccessState(input: { // runtime rows, because it is the only signal that looked inside the clone. const staleNotReadyFailure = failure?.reason === "workspace_not_ready" && readinessConfirmsServing; if (failure && !staleNotReadyFailure) { - if (failure.reason === "runtime_not_running" && !servingService) { + if (failure.reason === "runtime_not_running" && !servingService && !startingService) { return { - state: "provisioning", + state: "stopped", title: "Workspace is not running", description: "Start the workspace runtime to publish its board.", action: { kind: "start", label: "Start workspace" }, @@ -266,6 +271,16 @@ export function resolveWorkspaceAccessState(input: { } } + if (startingService) { + return { + state: "provisioning", + title: "Workspace is starting", + description: "Paperclip is starting the workspace runtime and waiting for its board URL.", + action: { kind: "wait", label: "Starting workspace" }, + handoffAvailable, + }; + } + if (servingService) { return { state: "ready", @@ -292,7 +307,7 @@ export function resolveWorkspaceAccessState(input: { } return { - state: "provisioning", + state: "stopped", title: "Workspace is not running", description: "Start the workspace runtime to publish its board.", action: { kind: "start", label: "Start workspace" }, diff --git a/ui/src/pages/Agents.test.tsx b/ui/src/pages/Agents.test.tsx index e01ccc43a1..8f44fa835d 100644 --- a/ui/src/pages/Agents.test.tsx +++ b/ui/src/pages/Agents.test.tsx @@ -242,14 +242,11 @@ function makeInstanceSettings({ enableIsolatedWorkspaces: true, enableStreamlinedLeftNavigation: false, enableConferenceRoomChat: false, - enableTaskWatchdogs: true, enableIssuePlanDecompositions: true, enableExperimentalFileViewer: false, enableExternalObjects: false, enableBuiltInAgents, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: false, - issueGraphLivenessAutoRecoveryLookbackHours: 24, }, createdAt: new Date("2026-01-01T00:00:00Z"), updatedAt: new Date("2026-01-01T00:00:00Z"), diff --git a/ui/src/pages/InstanceExperimentalSettings.test.tsx b/ui/src/pages/InstanceExperimentalSettings.test.tsx index a77826ba6f..56a442f453 100644 --- a/ui/src/pages/InstanceExperimentalSettings.test.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.test.tsx @@ -6,7 +6,6 @@ import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import type { InstanceExperimentalSettings as InstanceExperimentalSettingsPayload, InstanceExperimentalSettingsWithManaged, - IssueGraphLivenessAutoRecoveryPreview, } from "@paperclipai/shared"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { InstanceExperimentalSettings } from "./InstanceExperimentalSettings"; @@ -15,8 +14,6 @@ import { queryKeys } from "../lib/queryKeys"; const mockInstanceSettingsApi = vi.hoisted(() => ({ getExperimental: vi.fn(), updateExperimental: vi.fn(), - previewIssueGraphLivenessAutoRecovery: vi.fn(), - runIssueGraphLivenessAutoRecovery: vi.fn(), })); vi.mock("@/api/instanceSettings", () => ({ @@ -47,8 +44,6 @@ const CONFERENCE_TOGGLE_SELECTOR = 'button[aria-label="Toggle conference room chat experimental setting"]'; const STREAMLINED_TOGGLE_SELECTOR = 'button[aria-label="Toggle streamlined left navigation experimental setting"]'; -const TASK_WATCHDOGS_TOGGLE_SELECTOR = - 'button[aria-label="Toggle task watchdogs experimental setting"]'; const CLASSIC_TASK_INTERFACE_TOGGLE_SELECTOR = 'button[aria-label="Toggle classic task interface experimental setting"]'; const GOALS_SIDEBAR_LINK_TOGGLE_SELECTOR = @@ -68,8 +63,6 @@ const SUMMARIES_TOGGLE_SELECTOR = 'button[aria-label="Toggle summaries experimental setting"]'; const STATUS_CARDS_TOGGLE_SELECTOR = 'button[aria-label="Toggle status cards experimental setting"]'; -const AUTO_RECOVERY_TOGGLE_SELECTOR = - 'button[aria-label="Toggle task graph liveness auto-recovery"]'; const PAPERCLIP_RUNNER_TOGGLE_SELECTOR = 'button[aria-label="Toggle Paperclip Runner experimental setting"]'; @@ -94,14 +87,11 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { enableStatusCards: false, enableDecisions: false, enableGoalsSidebarLink: false, - enableTaskWatchdogs: false, enableServerInfoDebugView: false, enablePaperclipDeveloperMode: false, enableSimplifiedEnglishInteractions: false, enableSmokeLab: false, autoRestartDevServerWhenIdle: false, - enableIssueGraphLivenessAutoRecovery: false, - issueGraphLivenessAutoRecoveryLookbackHours: 24, enableWorkspaceBranchReconcileForward: true, enableWorkspaceDirtyQuarantineRepair: true, enableOwnerInstanceAdmin: false, @@ -113,18 +103,6 @@ function defaultExperimentalSettings(): InstanceExperimentalSettingsPayload { }; } -function emptyRecoveryPreview(): IssueGraphLivenessAutoRecoveryPreview { - return { - lookbackHours: 24, - cutoff: "2026-07-12T16:00:00.000Z", - generatedAt: "2026-07-13T16:00:00.000Z", - findings: 0, - recoverableFindings: 0, - skippedOutsideLookback: 0, - items: [], - }; -} - const WORKTREE_RUN_EXECUTION_TOGGLE_SELECTOR = 'button[aria-label="Toggle worktree run execution setting"]'; @@ -262,45 +240,11 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); }); - it("renders and patches the Task Watchdogs experimental toggle on and off", async () => { + it("does not render a Task Watchdogs toggle because watchdogs are always enabled", async () => { await renderPage(); - expect(container.textContent).toContain("Task Watchdogs"); - expect(container.textContent).toContain( - "Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.", - ); - - const toggle = container.querySelector(TASK_WATCHDOGS_TOGGLE_SELECTOR); - expect(toggle?.getAttribute("aria-checked")).toBe("false"); - - await act(async () => { - toggle?.click(); - }); - await flushReact(); - - expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ - enableTaskWatchdogs: true, - }); - expect(toggle?.getAttribute("aria-checked")).toBe("true"); - - flushSync(() => { - root?.unmount(); - }); - root = null; - container.textContent = ""; - await renderPage(); - - const enabledToggle = container.querySelector(TASK_WATCHDOGS_TOGGLE_SELECTOR); - expect(enabledToggle?.getAttribute("aria-checked")).toBe("true"); - - await act(async () => { - enabledToggle?.click(); - }); - await flushReact(); - - expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenLastCalledWith({ - enableTaskWatchdogs: false, - }); + expect(container.textContent).not.toContain("Task Watchdogs"); + expect(container.querySelector('button[aria-label="Toggle task watchdogs experimental setting"]')).toBeNull(); }); it("does not expose the retired Runner Preview Ingress setting separately", async () => { @@ -673,89 +617,6 @@ describe("InstanceExperimentalSettings — Conference Room Chat card (PAP-11233) expect(toggle?.getAttribute("aria-checked")).toBe("true"); }); - it("removes the auto-recovery confirmation overlay after enabling only", async () => { - mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(emptyRecoveryPreview()); - await renderPage(); - - const toggle = container.querySelector(AUTO_RECOVERY_TOGGLE_SELECTOR); - expect(toggle?.getAttribute("aria-checked")).toBe("false"); - - await act(async () => { - toggle?.click(); - }); - await flushReact(); - - expect(mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery).toHaveBeenCalledWith({ - lookbackHours: 24, - }); - expect(document.body.textContent).toContain("Confirm auto-recovery"); - expect(document.body.querySelector('[data-slot="dialog-overlay"]')).not.toBeNull(); - - const enableOnlyButton = [...document.body.querySelectorAll("button")].find( - (button) => button.textContent === "Enable only", - ); - - await act(async () => { - enableOnlyButton?.click(); - }); - await flushReact(); - - expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ - enableIssueGraphLivenessAutoRecovery: true, - issueGraphLivenessAutoRecoveryLookbackHours: 24, - }); - expect(document.body.textContent).not.toContain("Confirm auto-recovery"); - expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull(); - const enabledToggle = container.querySelector(AUTO_RECOVERY_TOGGLE_SELECTOR); - expect(enabledToggle?.getAttribute("aria-checked")).toBe("true"); - }); - - it("removes the auto-recovery confirmation overlay after enabling and running", async () => { - mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue(emptyRecoveryPreview()); - mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery.mockResolvedValue({ - findings: 0, - autoRecoveryEnabled: true, - lookbackHours: 24, - cutoff: "2026-07-12T16:00:00.000Z", - escalationsCreated: 0, - existingEscalations: 0, - skipped: 0, - skippedAutoRecoveryDisabled: 0, - }); - await renderPage(); - - const toggle = container.querySelector(AUTO_RECOVERY_TOGGLE_SELECTOR); - expect(toggle?.getAttribute("aria-checked")).toBe("false"); - - await act(async () => { - toggle?.click(); - }); - await flushReact(); - - expect(document.body.textContent).toContain("Confirm auto-recovery"); - expect(document.body.querySelector('[data-slot="dialog-overlay"]')).not.toBeNull(); - - const enableAndRunButton = [...document.body.querySelectorAll("button")].find( - (button) => button.textContent === "Enable", - ); - - await act(async () => { - enableAndRunButton?.click(); - }); - await flushReact(); - - expect(mockInstanceSettingsApi.updateExperimental).toHaveBeenCalledWith({ - enableIssueGraphLivenessAutoRecovery: true, - issueGraphLivenessAutoRecoveryLookbackHours: 24, - }); - expect(mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery).toHaveBeenCalledWith({ - lookbackHours: 24, - }); - expect(document.body.textContent).not.toContain("Confirm auto-recovery"); - expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull(); - const enabledToggle = container.querySelector(AUTO_RECOVERY_TOGGLE_SELECTOR); - expect(enabledToggle?.getAttribute("aria-checked")).toBe("true"); - }); }); describe("InstanceExperimentalSettings — cloud-managed keys", () => { @@ -795,6 +656,7 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => { root?.unmount(); }); root = null; + queryClient?.clear(); container.remove(); vi.clearAllMocks(); }); @@ -862,58 +724,6 @@ describe("InstanceExperimentalSettings — cloud-managed keys", () => { expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); }); - it("locks the managed auto-recovery toggle without opening the preview dialog", async () => { - await renderPage({ - ...defaultExperimentalSettings(), - managedKeys: { - enableIssueGraphLivenessAutoRecovery: { managed: true, managedBy: "paperclip-cloud" }, - }, - }); - - const toggle = container.querySelector(AUTO_RECOVERY_TOGGLE_SELECTOR); - expect(toggle?.disabled).toBe(true); - - await act(() => toggle?.click()); - await flushReact(); - - expect(mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery).not.toHaveBeenCalled(); - expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); - expect(document.body.textContent).not.toContain("Confirm auto-recovery"); - }); - - it("closes an open recovery preview when a refresh marks auto-recovery as managed", async () => { - mockInstanceSettingsApi.previewIssueGraphLivenessAutoRecovery.mockResolvedValue( - emptyRecoveryPreview(), - ); - const settings = defaultExperimentalSettings(); - await renderPage(settings); - - const toggle = container.querySelector(AUTO_RECOVERY_TOGGLE_SELECTOR); - await act(() => toggle?.click()); - await flushReact(); - expect(document.body.textContent).toContain("Confirm auto-recovery"); - - const managedSettings: InstanceExperimentalSettingsWithManaged = { - ...settings, - enableIssueGraphLivenessAutoRecovery: true, - managedKeys: { - enableIssueGraphLivenessAutoRecovery: { managed: true, managedBy: "paperclip-cloud" }, - }, - }; - await act(() => { - queryClient.setQueryData(queryKeys.instance.experimentalSettings, managedSettings); - }); - await flushReact(); - - expect(document.body.textContent).not.toContain("Confirm auto-recovery"); - expect(document.body.querySelector('[data-slot="dialog-overlay"]')).toBeNull(); - expect(mockInstanceSettingsApi.updateExperimental).not.toHaveBeenCalled(); - expect(mockInstanceSettingsApi.runIssueGraphLivenessAutoRecovery).not.toHaveBeenCalled(); - - const lockedToggle = container.querySelector(AUTO_RECOVERY_TOGGLE_SELECTOR); - expect(lockedToggle?.disabled).toBe(true); - }); - it("renders no managed badge and keeps toggles editable without managedKeys (self-hosted)", async () => { await renderPage(defaultExperimentalSettings()); @@ -963,23 +773,43 @@ describe("InstanceExperimentalSettings — card ordering and headings (PAP-393)" }); root = null; container.remove(); + setWorktreeRuntimeMeta(false); vi.clearAllMocks(); }); - it("renders every card heading in alphabetical order", async () => { + it("groups developer and legacy settings into sections", async () => { + setWorktreeRuntimeMeta(true); await renderPage(defaultExperimentalSettings()); - const headings = [...container.querySelectorAll("h2")].map( + const headings = [...container.querySelectorAll("section > div > h2")].map( (heading) => heading.textContent ?? "", ); + expect(headings).toEqual([ + "Experimental features", + "Paperclip Developer Mode", + "Legacy", + ]); - // Sanity: the page rendered a meaningful set of cards, not an empty list. - expect(headings.length).toBeGreaterThan(10); + const sections = [...container.querySelectorAll("section")]; + expect(sections.at(0)?.textContent).not.toContain("Run tasks in this worktree"); + expect(sections.at(0)?.textContent).not.toContain("Managed Environment Only"); + expect(sections.at(-2)?.textContent).toContain("Run tasks in this worktree"); + expect(sections.at(-2)?.textContent).toContain("Managed Environment Only"); + expect(sections.at(-2)?.textContent).toContain("Auto-Restart Dev Server When Idle"); + expect(sections.at(-2)?.textContent).toContain("Server Info Debug View"); + expect(sections.at(-2)?.textContent).toContain("Smoke Lab"); + expect(sections.at(-2)?.textContent).toContain("Task Plan Decomposition"); + expect(sections.at(-1)?.textContent).toContain("These features are going to be removed."); + expect(sections.at(-1)?.textContent).toContain("Classic Task Interface"); + expect(sections.at(-1)?.textContent).toContain("Goals Sidebar Link"); + }); - const alphabetical = [...headings].sort((a, b) => - a.localeCompare(b, undefined, { sensitivity: "base" }), - ); - expect(headings).toEqual(alphabetical); + it("renders setting cards without a background color", async () => { + await renderPage(defaultExperimentalSettings()); + + const cards = [...container.querySelectorAll('[data-slot="card"]')]; + expect(cards.length).toBeGreaterThan(10); + expect(cards.every((card) => card.classList.contains("bg-transparent"))).toBe(true); }); it("no longer renders an 'Experimental' secondary badge on any card", async () => { @@ -1033,7 +863,7 @@ describe("InstanceExperimentalSettings — operator-hidden cards", () => { expect(container.textContent).not.toContain("Enable Environments"); expect(container.textContent).toContain("Beta skills"); - expect(container.textContent).toContain("Task Watchdogs"); + expect(container.textContent).toContain("Apps"); }); it("shows every toggle when nothing is hidden", async () => { diff --git a/ui/src/pages/InstanceExperimentalSettings.tsx b/ui/src/pages/InstanceExperimentalSettings.tsx index cc123da50f..8247518218 100644 --- a/ui/src/pages/InstanceExperimentalSettings.tsx +++ b/ui/src/pages/InstanceExperimentalSettings.tsx @@ -1,11 +1,10 @@ import { useEffect, useState } from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { AlertTriangle, Clock, FlaskConical, Lock, Play, Search } from "lucide-react"; +import { AlertTriangle, FlaskConical, Lock, Play } from "lucide-react"; import type { InstanceExperimentalSettings, InstanceExperimentalSettingsWithManaged, InstanceFeatureKey, - IssueGraphLivenessAutoRecoveryPreview, ManagedSettingMetadata, PatchInstanceExperimentalSettings, } from "@paperclipai/shared"; @@ -19,25 +18,6 @@ import { ToggleSwitch } from "@/components/ui/toggle-switch"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card } from "@/components/ui/card"; -import { Input } from "@/components/ui/input"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogFooter, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; - -function issueHref(identifier: string | null, issueId: string) { - if (!identifier) return `/issues/${issueId}`; - const prefix = identifier.split("-")[0] || "PAP"; - return `/${prefix}/issues/${identifier}`; -} - -function formatRecoveryState(state: string) { - return state.replace(/_/g, " "); -} type WorktreeRunExecutionDisplayState = | { kind: "off" } @@ -114,11 +94,11 @@ function ExperimentalToggleCard({ const isManaged = managed?.managed === true; if (hiddenSettings.has(experimentalSettingKey(settingKey))) return null; return ( - +
-

{title}

+

{title}

{isManaged ? : null}

{description}

@@ -138,106 +118,10 @@ function ExperimentalToggleCard({ ); } -function RecoveryPreviewDialog({ - preview, - open, - onOpenChange, - onEnableOnly, - onEnableAndRun, - isPending, -}: { - preview: IssueGraphLivenessAutoRecoveryPreview | null; - open: boolean; - onOpenChange: (open: boolean) => void; - onEnableOnly: () => void; - onEnableAndRun: () => void; - isPending: boolean; -}) { - const count = preview?.recoverableFindings ?? 0; - return ( - - - - Confirm auto-recovery - - {preview - ? `${count} recovery ${count === 1 ? "task" : "tasks"} match the last ${preview.lookbackHours} hours.` - : "Checking recovery candidates before enabling."} - - - -
- {preview && preview.items.length === 0 ? ( -
- No recovery tasks would be created right now. Auto-recovery can still run for future liveness incidents in - this window. -
- ) : null} - - {preview?.items.map((item) => ( - -
- - {item.identifier ?? item.issueId} - - - {formatRecoveryState(item.state)} - -
-

{item.title}

-

{item.reason}

- -
- ))} -
- - {preview && preview.skippedOutsideLookback > 0 ? ( -

- {preview.skippedOutsideLookback} current{" "} - {preview.skippedOutsideLookback === 1 ? "finding is" : "findings are"} outside the configured lookback and - will not be touched. -

- ) : null} - - - - - - -
-
- ); -} - export function InstanceExperimentalSettings() { const { setBreadcrumbs } = useBreadcrumbs(); const queryClient = useQueryClient(); const [actionError, setActionError] = useState(null); - const [lookbackHoursDraft, setLookbackHoursDraft] = useState("24"); - const [previewDialogOpen, setPreviewDialogOpen] = useState(false); - const [pendingPreview, setPendingPreview] = useState(null); - - function closeRecoveryPreview() { - setPreviewDialogOpen(false); - setPendingPreview(null); - } useEffect(() => { setBreadcrumbs([ @@ -290,53 +174,6 @@ export function InstanceExperimentalSettings() { }, }); - const previewMutation = useMutation({ - mutationFn: async (lookbackHours: number) => - instanceSettingsApi.previewIssueGraphLivenessAutoRecovery({ lookbackHours }), - onSuccess: (preview) => { - setActionError(null); - setPendingPreview(preview); - setPreviewDialogOpen(true); - }, - onError: (error) => { - setActionError(error instanceof Error ? error.message : "Failed to preview recovery tasks."); - }, - }); - - const runRecoveryMutation = useMutation({ - mutationFn: async (lookbackHours: number) => - instanceSettingsApi.runIssueGraphLivenessAutoRecovery({ lookbackHours }), - onSuccess: async () => { - setActionError(null); - closeRecoveryPreview(); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: queryKeys.instance.experimentalSettings }), - queryClient.invalidateQueries({ queryKey: queryKeys.health }), - ]); - }, - onError: (error) => { - setActionError(error instanceof Error ? error.message : "Failed to create recovery tasks."); - }, - }); - - useEffect(() => { - const next = experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours; - if (typeof next === "number") { - setLookbackHoursDraft(String(next)); - } - }, [experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours]); - - const autoRecoveryManaged = - experimentalQuery.data?.managedKeys?.enableIssueGraphLivenessAutoRecovery?.managed === true; - - // If refreshed settings mark auto-recovery as managed while the preview - // dialog is open, close it so its confirmation actions cannot emit a PATCH. - useEffect(() => { - if (autoRecoveryManaged) { - closeRecoveryPreview(); - } - }, [autoRecoveryManaged]); - if (experimentalQuery.isLoading) { return
Loading experimental settings...
; } @@ -375,7 +212,6 @@ export function InstanceExperimentalSettings() { experimentalQuery.data?.enableIssuePlanDecompositions === true; const enableExperimentalFileViewer = experimentalQuery.data?.enableExperimentalFileViewer === true; - const enableTaskWatchdogs = experimentalQuery.data?.enableTaskWatchdogs === true; const enableExternalObjects = experimentalQuery.data?.enableExternalObjects === true; const enableBuiltInAgents = experimentalQuery.data?.enableBuiltInAgents === true; const enableBetaSkills = experimentalQuery.data?.enableBetaSkills === true; @@ -395,48 +231,6 @@ export function InstanceExperimentalSettings() { experimentalQuery.data?.enableSimplifiedEnglishInteractions === true; const enableSmokeLab = experimentalQuery.data?.enableSmokeLab === true; const autoRestartDevServerWhenIdle = experimentalQuery.data?.autoRestartDevServerWhenIdle === true; - const enableIssueGraphLivenessAutoRecovery = - experimentalQuery.data?.enableIssueGraphLivenessAutoRecovery === true; - const lookbackHours = - experimentalQuery.data?.issueGraphLivenessAutoRecoveryLookbackHours ?? 24; - const parsedLookbackHours = Number.parseInt(lookbackHoursDraft, 10); - const lookbackHoursIsValid = - Number.isInteger(parsedLookbackHours) && parsedLookbackHours >= 1 && parsedLookbackHours <= 720; - const recoveryActionPending = - toggleMutation.isPending || previewMutation.isPending || runRecoveryMutation.isPending; - - function previewForEnable() { - if (autoRecoveryManaged) return; - if (!lookbackHoursIsValid) { - setActionError("Lookback hours must be a whole number from 1 to 720."); - return; - } - closeRecoveryPreview(); - previewMutation.mutate(parsedLookbackHours); - } - - function enableOnly() { - if (autoRecoveryManaged) return; - if (!lookbackHoursIsValid) return; - closeRecoveryPreview(); - toggleMutation.mutate({ - enableIssueGraphLivenessAutoRecovery: true, - issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours, - }); - } - - function enableAndRun() { - if (autoRecoveryManaged) return; - if (!lookbackHoursIsValid) return; - closeRecoveryPreview(); - toggleMutation.mutate({ - enableIssueGraphLivenessAutoRecovery: true, - issueGraphLivenessAutoRecoveryLookbackHours: parsedLookbackHours, - }, { - onSuccess: () => runRecoveryMutation.mutate(parsedLookbackHours), - }); - } - return (
@@ -471,444 +265,372 @@ export function InstanceExperimentalSettings() {
)} - toggleMutation.mutate({ enableApps: checked })} - disabled={toggleMutation.isPending} - settingKey="enableApps" - managed={managedKeys.enableApps} - ariaLabel="Toggle apps experimental setting" - /> - - -
-
-
-
-

Auto-Create Recovery Tasks

- {autoRecoveryManaged ? : null} -
-

- Let the heartbeat scheduler create recovery tasks for task dependency chains found inside the - configured lookback window. -

-
- { - if (autoRecoveryManaged) return; - if (enableIssueGraphLivenessAutoRecovery) { - toggleMutation.mutate({ enableIssueGraphLivenessAutoRecovery: false }); - return; - } - previewForEnable(); - }} - disabled={recoveryActionPending || autoRecoveryManaged} - aria-label="Toggle task graph liveness auto-recovery" - /> -
- -
- -
- - - -
-
- -

- Current window: last {lookbackHours} {lookbackHours === 1 ? "hour" : "hours"}. +

+
+

+ Experimental features +

+

+ Optional product features that are still being evaluated.

- - toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked })} - disabled={toggleMutation.isPending} - settingKey="autoRestartDevServerWhenIdle" - managed={managedKeys.autoRestartDevServerWhenIdle} - ariaLabel="Toggle guarded dev-server auto-restart" - /> - - toggleMutation.mutate({ enableBetaSkills: checked })} - disabled={toggleMutation.isPending} - settingKey="enableBetaSkills" - managed={managedKeys.enableBetaSkills} - ariaLabel="Toggle beta skills experimental setting" - /> - - toggleMutation.mutate({ enableBuiltInAgents: checked })} - disabled={toggleMutation.isPending} - settingKey="enableBuiltInAgents" - managed={managedKeys.enableBuiltInAgents} - ariaLabel="Toggle built-in agents experimental setting" - /> - - toggleMutation.mutate({ enableCases: checked })} - disabled={toggleMutation.isPending} - settingKey="enableCases" - managed={managedKeys.enableCases} - ariaLabel="Toggle cases experimental setting" - /> - - toggleMutation.mutate({ enableClassicTaskInterface: checked })} - disabled={toggleMutation.isPending} - settingKey="enableClassicTaskInterface" - managed={managedKeys.enableClassicTaskInterface} - ariaLabel="Toggle classic task interface experimental setting" - /> - - {SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? ( toggleMutation.mutate({ enableConferenceRoomChat: checked })} + title="Apps" + description="Show the Apps navigation and allow access to app connections, gateways, and advanced app tooling." + checked={enableApps} + onCheckedChange={(checked) => toggleMutation.mutate({ enableApps: checked })} disabled={toggleMutation.isPending} - settingKey="enableConferenceRoomChat" - managed={managedKeys.enableConferenceRoomChat} - ariaLabel="Toggle conference room chat experimental setting" + settingKey="enableApps" + managed={managedKeys.enableApps} + ariaLabel="Toggle apps experimental setting" /> - ) : null} - toggleMutation.mutate({ enableDecisions: checked })} - disabled={toggleMutation.isPending} - settingKey="enableDecisions" - managed={managedKeys.enableDecisions} - ariaLabel="Toggle decisions experimental setting" - /> + toggleMutation.mutate({ enableBetaSkills: checked })} + disabled={toggleMutation.isPending} + settingKey="enableBetaSkills" + managed={managedKeys.enableBetaSkills} + ariaLabel="Toggle beta skills experimental setting" + /> - toggleMutation.mutate({ enableEnvironments: checked })} - disabled={toggleMutation.isPending} - settingKey="enableEnvironments" - managed={managedKeys.enableEnvironments} - ariaLabel="Toggle environments experimental setting" - /> + toggleMutation.mutate({ enableBuiltInAgents: checked })} + disabled={toggleMutation.isPending} + settingKey="enableBuiltInAgents" + managed={managedKeys.enableBuiltInAgents} + ariaLabel="Toggle built-in agents experimental setting" + /> - toggleMutation.mutate({ enableExternalObjects: checked })} - disabled={toggleMutation.isPending} - settingKey="enableExternalObjects" - managed={managedKeys.enableExternalObjects} - ariaLabel="Toggle external objects experimental setting" - /> + toggleMutation.mutate({ enableCases: checked })} + disabled={toggleMutation.isPending} + settingKey="enableCases" + managed={managedKeys.enableCases} + ariaLabel="Toggle cases experimental setting" + /> - toggleMutation.mutate({ enableIsolatedWorkspaces: checked })} - disabled={toggleMutation.isPending} - settingKey="enableIsolatedWorkspaces" - managed={managedKeys.enableIsolatedWorkspaces} - ariaLabel="Toggle isolated workspaces experimental setting" - /> + {SHOW_CONFERENCE_ROOM_EXPERIMENTAL_SETTING ? ( + toggleMutation.mutate({ enableConferenceRoomChat: checked })} + disabled={toggleMutation.isPending} + settingKey="enableConferenceRoomChat" + managed={managedKeys.enableConferenceRoomChat} + ariaLabel="Toggle conference room chat experimental setting" + /> + ) : null} - toggleMutation.mutate({ enableExperimentalFileViewer: checked })} - disabled={toggleMutation.isPending} - settingKey="enableExperimentalFileViewer" - managed={managedKeys.enableExperimentalFileViewer} - ariaLabel="Toggle experimental file viewer setting" - /> + toggleMutation.mutate({ enableDecisions: checked })} + disabled={toggleMutation.isPending} + settingKey="enableDecisions" + managed={managedKeys.enableDecisions} + ariaLabel="Toggle decisions experimental setting" + /> - toggleMutation.mutate({ enableGoalsSidebarLink: checked })} - disabled={toggleMutation.isPending} - settingKey="enableGoalsSidebarLink" - managed={managedKeys.enableGoalsSidebarLink} - ariaLabel="Toggle goals sidebar link experimental setting" - /> + toggleMutation.mutate({ enableEnvironments: checked })} + disabled={toggleMutation.isPending} + settingKey="enableEnvironments" + managed={managedKeys.enableEnvironments} + ariaLabel="Toggle environments experimental setting" + /> - toggleMutation.mutate({ enableManagedSandboxOnly: checked })} - disabled={toggleMutation.isPending} - settingKey="enableManagedSandboxOnly" - managed={managedKeys.enableManagedSandboxOnly} - ariaLabel="Toggle managed environment only experimental setting" - /> + toggleMutation.mutate({ enableExternalObjects: checked })} + disabled={toggleMutation.isPending} + settingKey="enableExternalObjects" + managed={managedKeys.enableExternalObjects} + ariaLabel="Toggle external objects experimental setting" + /> - - toggleMutation.mutate({ enablePaperclipDeveloperMode: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enablePaperclipDeveloperMode" - managed={managedKeys.enablePaperclipDeveloperMode} - ariaLabel="Toggle Paperclip developer mode experimental setting" - /> + toggleMutation.mutate({ enableIsolatedWorkspaces: checked })} + disabled={toggleMutation.isPending} + settingKey="enableIsolatedWorkspaces" + managed={managedKeys.enableIsolatedWorkspaces} + ariaLabel="Toggle isolated workspaces experimental setting" + /> - - toggleMutation.mutate({ enableNativeRunner: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableNativeRunner" - managed={managedKeys.enableNativeRunner} - ariaLabel="Toggle Paperclip Runner experimental setting" - /> + toggleMutation.mutate({ enableExperimentalFileViewer: checked })} + disabled={toggleMutation.isPending} + settingKey="enableExperimentalFileViewer" + managed={managedKeys.enableExperimentalFileViewer} + ariaLabel="Toggle experimental file viewer setting" + /> - {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" - /> -
+ + toggleMutation.mutate({ enableNativeRunner: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableNativeRunner" + managed={managedKeys.enableNativeRunner} + ariaLabel="Toggle Paperclip Runner experimental setting" + /> - {worktreeRunExecutionState.kind === "armed" ? ( -
- - - Running tasks created after{" "} - - {formatActivationTimestamp(worktreeRunExecutionState.activatedAt)} - - . - -
- ) : null} + + toggleMutation.mutate({ enableSimplifiedEnglishInteractions: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableSimplifiedEnglishInteractions" + managed={managedKeys.enableSimplifiedEnglishInteractions} + ariaLabel="Toggle simplified english interactions experimental setting" + /> - {worktreeRunExecutionState.kind === "fail_closed" ? ( -
- -
-

Execution is suppressed — effectively off.

-

- {worktreeRunExecutionState.reason === "instance_mismatch" - ? "This setting was armed in a different instance and copied here, so no tasks run automatically." - : "This setting is missing its activation cutoff, so no tasks run automatically."}{" "} - Toggle it off and back on to arm execution for tasks created here. + + toggleMutation.mutate( + checked + ? { enableSummaries: true, enableStatusCards: true } + : { enableStatusCards: false }, + ) + } + disabled={toggleMutation.isPending || statusCardsBlockedByManagedSummaries} + settingKey="enableStatusCards" + managed={managedKeys.enableStatusCards} + ariaLabel="Toggle status cards experimental setting" + /> + + + toggleMutation.mutate( + checked || !enableStatusCards + ? { enableSummaries: checked } + : { enableSummaries: false, enableStatusCards: false }, + ) + } + disabled={toggleMutation.isPending || summariesRequiredByManagedStatusCards} + settingKey="enableSummaries" + managed={managedKeys.enableSummaries} + ariaLabel="Toggle summaries experimental setting" + /> + +

+ +
+
+

+ Paperclip Developer Mode +

+

+ Internal tools for developing, testing, and debugging Paperclip. +

+
+ + + toggleMutation.mutate({ enablePaperclipDeveloperMode: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enablePaperclipDeveloperMode" + managed={managedKeys.enablePaperclipDeveloperMode} + ariaLabel="Toggle Paperclip developer mode experimental setting" + /> + + + toggleMutation.mutate({ enableManagedSandboxOnly: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableManagedSandboxOnly" + managed={managedKeys.enableManagedSandboxOnly} + ariaLabel="Toggle managed environment only experimental setting" + /> + + {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} -
-
- ) : null} - toggleMutation.mutate({ enableServerInfoDebugView: checked })} - disabled={toggleMutation.isPending} - settingKey="enableServerInfoDebugView" - managed={managedKeys.enableServerInfoDebugView} - ariaLabel="Toggle server info debug view experimental setting" - /> + {worktreeRunExecutionState.kind === "armed" ? ( +
+ + + Running tasks created after{" "} + + {formatActivationTimestamp(worktreeRunExecutionState.activatedAt)} + + . + +
+ ) : null} - - toggleMutation.mutate({ enableSimplifiedEnglishInteractions: checked }) - } - disabled={toggleMutation.isPending} - settingKey="enableSimplifiedEnglishInteractions" - managed={managedKeys.enableSimplifiedEnglishInteractions} - ariaLabel="Toggle simplified english interactions experimental setting" - /> + {worktreeRunExecutionState.kind === "fail_closed" ? ( +
+ +
+

Execution is suppressed — effectively off.

+

+ {worktreeRunExecutionState.reason === "instance_mismatch" + ? "This setting was armed in a different instance and copied here, so no tasks run automatically." + : "This setting is missing its activation cutoff, so no tasks run automatically."}{" "} + Toggle it off and back on to arm execution for tasks created here. +

+
+
+ ) : null} +
+
+ ) : null} - toggleMutation.mutate({ enableSmokeLab: checked })} - disabled={toggleMutation.isPending} - settingKey="enableSmokeLab" - managed={managedKeys.enableSmokeLab} - ariaLabel="Toggle smoke lab experimental setting" - /> - - - toggleMutation.mutate( - checked - ? { enableSummaries: true, enableStatusCards: true } - : { enableStatusCards: false }, - ) - } - disabled={toggleMutation.isPending || statusCardsBlockedByManagedSummaries} - settingKey="enableStatusCards" - managed={managedKeys.enableStatusCards} - ariaLabel="Toggle status cards experimental setting" - /> - - - toggleMutation.mutate( - checked || !enableStatusCards - ? { enableSummaries: checked } - : { enableSummaries: false, enableStatusCards: false }, - ) - } - disabled={toggleMutation.isPending || summariesRequiredByManagedStatusCards} - settingKey="enableSummaries" - managed={managedKeys.enableSummaries} - ariaLabel="Toggle summaries experimental setting" - /> - - toggleMutation.mutate({ enableIssuePlanDecompositions: checked })} - disabled={toggleMutation.isPending} - settingKey="enableIssuePlanDecompositions" - managed={managedKeys.enableIssuePlanDecompositions} - ariaLabel="Toggle task plan decomposition panel experimental setting" - /> - - toggleMutation.mutate({ enableTaskWatchdogs: checked })} - disabled={toggleMutation.isPending} - settingKey="enableTaskWatchdogs" - managed={managedKeys.enableTaskWatchdogs} - ariaLabel="Toggle task watchdogs experimental setting" - /> - - {previewDialogOpen && !autoRecoveryManaged ? ( - { - if (!open) { - closeRecoveryPreview(); - } - }} - preview={pendingPreview} - onEnableOnly={enableOnly} - onEnableAndRun={enableAndRun} - isPending={recoveryActionPending} + + toggleMutation.mutate({ autoRestartDevServerWhenIdle: checked }) + } + disabled={toggleMutation.isPending} + settingKey="autoRestartDevServerWhenIdle" + managed={managedKeys.autoRestartDevServerWhenIdle} + ariaLabel="Toggle guarded dev-server auto-restart" /> - ) : null} + + + toggleMutation.mutate({ enableServerInfoDebugView: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableServerInfoDebugView" + managed={managedKeys.enableServerInfoDebugView} + ariaLabel="Toggle server info debug view experimental setting" + /> + + toggleMutation.mutate({ enableSmokeLab: checked })} + disabled={toggleMutation.isPending} + settingKey="enableSmokeLab" + managed={managedKeys.enableSmokeLab} + ariaLabel="Toggle smoke lab experimental setting" + /> + + + toggleMutation.mutate({ enableIssuePlanDecompositions: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableIssuePlanDecompositions" + managed={managedKeys.enableIssuePlanDecompositions} + ariaLabel="Toggle task plan decomposition panel experimental setting" + /> + + +
+
+

+ Legacy +

+

These features are going to be removed.

+
+ + + toggleMutation.mutate({ enableClassicTaskInterface: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableClassicTaskInterface" + managed={managedKeys.enableClassicTaskInterface} + ariaLabel="Toggle classic task interface experimental setting" + /> + + + toggleMutation.mutate({ enableGoalsSidebarLink: checked }) + } + disabled={toggleMutation.isPending} + settingKey="enableGoalsSidebarLink" + managed={managedKeys.enableGoalsSidebarLink} + ariaLabel="Toggle goals sidebar link experimental setting" + /> +
); }