Clean up experimental settings features (#12681)

## 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 <noreply@paperclip.ing>
This commit is contained in:
Dotta 2026-09-01 14:23:05 -05:00 committed by GitHub
parent 24a674f885
commit 141f202e40
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
37 changed files with 810 additions and 3134 deletions

View File

@ -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.

View File

@ -120,16 +120,8 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableTaskWatchdogs: {
title: "Task Watchdogs",
description:
"Show task detail controls for configuring watchdog agents that verify stopped task subtrees and restore live paths when work should continue.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableIssuePlanDecompositions: {
title: "Task Plan Decomposition Panel",
title: "Task Plan Decomposition",
description: "Show accepted-plan decomposition history on task detail pages.",
tier: "managed",
cloudDefault: false,
@ -237,14 +229,6 @@ export const INSTANCE_FEATURE_CATALOG: Record<InstanceFeatureKey, FeatureCatalog
cloudDefault: false,
selfHostedDefault: false,
},
enableIssueGraphLivenessAutoRecovery: {
title: "Auto-Create Recovery Tasks",
description:
"Let the heartbeat scheduler create recovery tasks for task dependency chains found inside the configured lookback window.",
tier: "managed",
cloudDefault: false,
selfHostedDefault: false,
},
enableWorkspaceBranchReconcileForward: {
title: "Workspace Branch Reconcile Forward",
description:

View File

@ -892,8 +892,6 @@ export type {
InstanceSettings,
ManagedExperimentalFeatureKey,
ManagedSettingMetadata,
IssueGraphLivenessAutoRecoveryPreview,
IssueGraphLivenessAutoRecoveryPreviewItem,
BackupRetentionPolicy,
Agent,
AgentAccessState,
@ -1726,9 +1724,6 @@ export {
WEEKLY_RETENTION_PRESETS,
MONTHLY_RETENTION_PRESETS,
DEFAULT_BACKUP_RETENTION,
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
PAPERCLIP_CLOUD_MANAGED_BY,
} from "./types/instance.js";
@ -1772,7 +1767,6 @@ export {
managedSettingMetadataSchema,
patchInstanceExperimentalSettingsSchema,
patchInstanceSettingsSchema,
issueGraphLivenessAutoRecoveryRequestSchema,
createSmokeRunSchema,
updateSmokeRunSchema,
recordSmokeRunStepSchema,
@ -1788,7 +1782,6 @@ export {
trustAuthorizationPolicySchema,
type PatchInstanceExperimentalSettings,
type PatchInstanceSettings,
type IssueGraphLivenessAutoRecoveryRequest,
type CreateSmokeRun,
type UpdateSmokeRun,
type RecordSmokeRunStep,

View File

@ -107,8 +107,6 @@ export type {
ManagedExperimentalFeatureKey,
ManagedSettingMetadata,
BackupRetentionPolicy,
IssueGraphLivenessAutoRecoveryPreview,
IssueGraphLivenessAutoRecoveryPreviewItem,
} from "./instance.js";
export type {
SmokeLabServiceStatus,
@ -130,9 +128,6 @@ export {
WEEKLY_RETENTION_PRESETS,
MONTHLY_RETENTION_PRESETS,
DEFAULT_BACKUP_RETENTION,
DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS,
PAPERCLIP_CLOUD_MANAGED_BY,
} from "./instance.js";
export {

View File

@ -3,10 +3,6 @@ import type { FeedbackDataSharingPreference } from "./feedback.js";
export const DAILY_RETENTION_PRESETS = [3, 7, 14] as const;
export const WEEKLY_RETENTION_PRESETS = [1, 2, 4] as const;
export const MONTHLY_RETENTION_PRESETS = [1, 3, 6] as const;
export const DEFAULT_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS = 24;
export const MIN_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS = 1;
export const MAX_ISSUE_GRAPH_LIVENESS_AUTO_RECOVERY_LOOKBACK_HOURS = 24 * 30;
export interface BackupRetentionPolicy {
dailyDays: (typeof DAILY_RETENTION_PRESETS)[number];
weeklyWeeks: (typeof WEEKLY_RETENTION_PRESETS)[number];
@ -63,7 +59,6 @@ export interface InstanceExperimentalSettings {
enableCases: boolean;
enableConferenceRoomChat: boolean;
enableClassicTaskInterface: boolean;
enableTaskWatchdogs: boolean;
enableIssuePlanDecompositions: boolean;
enableExperimentalFileViewer: boolean;
enableExternalObjects: boolean;
@ -85,7 +80,6 @@ export interface InstanceExperimentalSettings {
*/
enableSimplifiedEnglishInteractions: boolean;
autoRestartDevServerWhenIdle: boolean;
enableIssueGraphLivenessAutoRecovery: boolean;
enableWorkspaceBranchReconcileForward: boolean;
enableWorkspaceDirtyQuarantineRepair: boolean;
/**
@ -125,7 +119,6 @@ export interface InstanceExperimentalSettings {
* from another instance fail closed.
*/
worktreeRunExecutionActivationInstanceId: string | null;
issueGraphLivenessAutoRecoveryLookbackHours: number;
}
/**
@ -164,34 +157,3 @@ export interface InstanceSettings {
createdAt: Date;
updatedAt: Date;
}
export interface IssueGraphLivenessAutoRecoveryPreviewItem {
issueId: string;
identifier: string | null;
title: string;
state: string;
severity: string;
reason: string;
recoveryIssueId: string;
recoveryIdentifier: string | null;
recoveryTitle: string | null;
recommendedOwnerAgentId: string | null;
incidentKey: string;
latestDependencyUpdatedAt: string;
dependencyPath: Array<{
issueId: string;
identifier: string | null;
title: string;
status: string;
}>;
}
export interface IssueGraphLivenessAutoRecoveryPreview {
lookbackHours: number;
cutoff: string;
generatedAt: string;
findings: number;
recoverableFindings: number;
skippedOutsideLookback: number;
items: IssueGraphLivenessAutoRecoveryPreviewItem[];
}

View File

@ -48,11 +48,9 @@ export {
managedSettingMetadataSchema,
patchInstanceExperimentalSettingsSchema,
patchInstanceSettingsSchema,
issueGraphLivenessAutoRecoveryRequestSchema,
type InstanceExperimentalSettings,
type PatchInstanceExperimentalSettings,
type PatchInstanceSettings,
type IssueGraphLivenessAutoRecoveryRequest,
} from "./instance.js";
export {

View File

@ -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({});

View File

@ -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<typeof patchInstanceSettingsSchema>;
export type IssueGraphLivenessAutoRecoveryRequest = z.infer<
typeof issueGraphLivenessAutoRecoveryRequestSchema
>;
export type StartTaskDrainRequest = z.infer<typeof startTaskDrainRequestSchema>;
export const instanceSettingsSchema = z.object({

View File

@ -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<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
let db: ReturnType<typeof createDb>;
@ -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);
});
});

View File

@ -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();

View File

@ -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);

View File

@ -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);
});
});

View File

@ -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 })),

View File

@ -1316,11 +1316,11 @@ export async function startServer(): Promise<StartedServer> {
);
}
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<StartedServer> {
}
})
.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 () => {

View File

@ -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());

View File

@ -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",

View File

@ -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,

View File

@ -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,
};
}

File diff suppressed because it is too large Load Diff

View File

@ -69,6 +69,13 @@ vi.mock("./pages/audit/CompanyActivity", () => ({
},
}));
vi.mock("./pages/Issues", () => ({
Issues: () => {
const location = useLocation();
return <div>{`TASKS_PAGE@${location.pathname}`}</div>;
},
}));
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());
});
});

View File

@ -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() {
<Route path="workspaces" element={<Workspaces />} />
</Route>
<Route path="issues" element={<Issues />} />
<Route path="tasks" element={<Navigate to="/issues" replace />} />
<Route path="search" element={<Search />} />
<Route path="issues/all" element={<Navigate to="/issues" replace />} />
<Route path="issues/active" element={<Navigate to="/issues" replace />} />
@ -304,12 +309,14 @@ function boardRoutes() {
<Route path="routines/:routineId" element={<RoutineDetail />} />
<Route path="routines/:routineId/:section" element={<RoutineDetail />} />
<Route element={<IsolatedWorkspacesRouteGate />}>
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
<Route element={<ExecutionWorkspaceCompanyGate />}>
<Route path="execution-workspaces/:workspaceId" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/services" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<ExecutionWorkspaceDetail />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<ExecutionWorkspaceDetail />} />
</Route>
</Route>
<Route path="goals" element={<Goals />} />
<Route path="goals/:goalId" element={<GoalDetail />} />
@ -664,6 +671,7 @@ export function App() {
<Route path="instance/settings/*" element={<LegacySettingsRedirect />} />
<Route path="companies" element={<UnprefixedBoardRedirect />} />
<Route path="issues" element={<UnprefixedBoardRedirect />} />
<Route path="tasks" element={<UnprefixedBoardRedirect />} />
<Route path="issues/:issueId" element={<UnprefixedBoardRedirect />} />
<Route path="routines" element={<UnprefixedBoardRedirect />} />
<Route path="routines/:routineId" element={<UnprefixedBoardRedirect />} />
@ -709,12 +717,12 @@ export function App() {
<Route path="projects/:projectId/workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="projects/:projectId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="workspaces" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedBoardRedirect />} />
<Route path="execution-workspaces/:workspaceId" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/services" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/configuration" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/runtime-logs" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path="execution-workspaces/:workspaceId/routines" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path=":companyPrefix" element={<Layout />}>
{boardRoutes()}
</Route>

View File

@ -2,7 +2,6 @@ import type {
InstanceExperimentalSettingsWithManaged,
InstanceGeneralSettings,
InstanceSettings,
IssueGraphLivenessAutoRecoveryPreview,
PatchInstanceSettings,
PatchInstanceGeneralSettings,
PatchInstanceExperimentalSettings,
@ -22,36 +21,4 @@ export const instanceSettingsApi = {
api.get<InstanceExperimentalSettingsWithManaged>("/instance/settings/experimental"),
updateExperimental: (patch: PatchInstanceExperimentalSettings) =>
api.patch<InstanceExperimentalSettingsWithManaged>("/instance/settings/experimental", patch),
previewIssueGraphLivenessAutoRecovery: (input: { lookbackHours?: number }) =>
api.post<IssueGraphLivenessAutoRecoveryPreview>(
"/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,
),
};

View File

@ -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, {

View File

@ -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",

View File

@ -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() {
<button
type="button"
className="inline-flex items-center justify-center rounded-md p-1 text-muted-foreground hover:bg-accent/50 transition-colors"
title={taskWatchdogsEnabled ? "Add reviewer, approver, or watchdog" : "Add reviewer or approver"}
title="Add reviewer, approver, or watchdog"
>
<MoreHorizontal className="h-4 w-4" />
</button>
@ -1640,29 +1639,27 @@ export function NewIssueDialog() {
<ShieldCheck className="h-3 w-3" />
Approver
</button>
{taskWatchdogsEnabled && (
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
showWatchdogRow && "bg-accent",
)}
onClick={() => {
if (showWatchdogRow) {
setShowWatchdogRow(false);
setWatchdogAgentId("");
setWatchdogInstructions("");
setWatchdogEditorOpen(false);
} else {
setShowWatchdogRow(true);
setWatchdogEditorOpen(true);
}
setParticipantMenuOpen(false);
}}
>
<ScanEye className="h-3 w-3" />
Watchdog
</button>
)}
<button
className={cn(
"flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50",
showWatchdogRow && "bg-accent",
)}
onClick={() => {
if (showWatchdogRow) {
setShowWatchdogRow(false);
setWatchdogAgentId("");
setWatchdogInstructions("");
setWatchdogEditorOpen(false);
} else {
setShowWatchdogRow(true);
setWatchdogEditorOpen(true);
}
setParticipantMenuOpen(false);
}}
>
<ScanEye className="h-3 w-3" />
Watchdog
</button>
</PopoverContent>
</Popover>
</div>
@ -1759,7 +1756,7 @@ export function NewIssueDialog() {
)}
{/* Watchdog row */}
{taskWatchdogsEnabled && showWatchdogRow && (
{showWatchdogRow && (
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-1">
<span className="w-6 shrink-0 flex items-center justify-center"><ScanEye className="h-3.5 w-3.5" /></span>
<Popover open={watchdogEditorOpen} onOpenChange={setWatchdogEditorOpen}>

View File

@ -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: () => <div>NOT_FOUND</div>,
}));
function Destination() {
const location = useLocation();
return <div>{`DESTINATION@${location.pathname}${location.search}${location.hash}`}</div>;
}
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(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path="execution-workspaces/:workspaceId/issues" element={<UnprefixedExecutionWorkspaceRedirect />} />
<Route path=":companyPrefix/execution-workspaces/:workspaceId/issues" element={<Destination />} />
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
}
function renderGate(path: string) {
root = createRoot(container);
const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } } });
flushSync(() => {
root.render(
<QueryClientProvider client={queryClient}>
<MemoryRouter initialEntries={[path]}>
<Routes>
<Route path=":companyPrefix" element={<ExecutionWorkspaceCompanyGate />}>
<Route path="execution-workspaces/:workspaceId/issues" element={<Destination />} />
</Route>
</Routes>
</MemoryRouter>
</QueryClientProvider>,
);
});
}
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@");
});
});

View File

@ -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 <NotFoundPage scope="global" />;
if (companiesLoading || workspaceQuery.isPending) return <PaperclipLoading />;
if (workspaceQuery.isError) return <NotFoundPage scope="global" />;
const targetCompany = companies.find(
(company) => company.id === workspaceQuery.data.companyId,
);
if (!targetCompany) return <NotFoundPage scope="global" />;
return (
<Navigate
to={`/${targetCompany.issuePrefix}${location.pathname}${location.search}${location.hash}`}
replace
/>
);
}
/** 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 <NotFoundPage scope="global" />;
if (companiesLoading || workspaceQuery.isPending) return <PaperclipLoading />;
if (workspaceQuery.isError) return <NotFoundPage scope="global" />;
const routeCompany = companies.find(
(company) => company.issuePrefix.toUpperCase() === companyPrefix.toUpperCase(),
);
if (!routeCompany || routeCompany.id !== workspaceQuery.data.companyId) {
return <NotFoundPage scope="global" />;
}
return <Outlet />;
}

View File

@ -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({

View File

@ -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<WorkspaceAccessState["state"], string> = {
type ActiveWorkspaceAccessState = Exclude<WorkspaceAccessState["state"], "stopped">;
const STATE_BADGE_CLASSES: Record<ActiveWorkspaceAccessState, string> = {
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<WorkspaceAccessState["state"], string> = {
failed: "border-destructive/50 text-destructive",
};
const STATE_LABELS: Record<WorkspaceAccessState["state"], string> = {
const STATE_LABELS: Record<ActiveWorkspaceAccessState, string> = {
provisioning: "Provisioning",
validating: "Validating clone",
ready: "Ready",
@ -80,18 +82,20 @@ export function WorkspaceAccessCard({
<CardHeader>
<div className="flex flex-wrap items-center gap-2">
<CardTitle>{access.title}</CardTitle>
<span
data-testid="workspace-access-badge"
className={cn(
"inline-flex items-center rounded-full border bg-background px-2.5 py-1 text-xs",
STATE_BADGE_CLASSES[access.state],
)}
>
{(access.state === "repairing" || access.state === "provisioning") && (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
)}
{STATE_LABELS[access.state]}
</span>
{access.state !== "stopped" ? (
<span
data-testid="workspace-access-badge"
className={cn(
"inline-flex items-center rounded-full border bg-background px-2.5 py-1 text-xs",
STATE_BADGE_CLASSES[access.state],
)}
>
{(access.state === "repairing" || access.state === "provisioning") && (
<Loader2 className="mr-1.5 h-3 w-3 animate-spin" />
)}
{STATE_LABELS[access.state]}
</span>
) : null}
</div>
<CardDescription>{access.description}</CardDescription>
</CardHeader>

View File

@ -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}
</PropertyPicker>
{taskWatchdogsEnabled ? (
<PropertyPicker
inline={inline}
label="Watchdog"
open={watchdogOpen}
onOpenChange={setWatchdogOpen}
triggerContent={watchdogTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-96")}
extra={
watchdogIssueRef ? (
<Link
to={`/issues/${watchdogIssueRef.id}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
title="Open watchdog task"
aria-label="Open watchdog task"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined
}
>
{watchdogContent}
</PropertyPicker>
) : null}
<PropertyPicker
inline={inline}
label="Watchdog"
open={watchdogOpen}
onOpenChange={setWatchdogOpen}
triggerContent={watchdogTrigger}
triggerClassName="min-w-0 max-w-full"
popoverClassName={cn("max-w-full", inline ? "w-full" : "w-80 sm:w-96")}
extra={
watchdogIssueRef ? (
<Link
to={`/issues/${watchdogIssueRef.id}`}
className="inline-flex items-center justify-center h-5 w-5 rounded hover:bg-accent/50 transition-colors text-muted-foreground hover:text-foreground"
title="Open watchdog task"
aria-label="Open watchdog task"
onClick={(e) => e.stopPropagation()}
>
<ArrowUpRight className="h-3 w-3" />
</Link>
) : undefined
}
>
{watchdogContent}
</PropertyPicker>
</PropertySection>
{hasWorkspaceRuntimeControls || issue.currentExecutionWorkspace?.branchName || issue.currentExecutionWorkspace?.cwd || issue.executionWorkspaceId ? (

View File

@ -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);

View File

@ -11,6 +11,7 @@ const BOARD_ROUTE_ROOTS = new Set([
"workspaces",
"execution-workspaces",
"issues",
"tasks",
"routines",
"goals",
"artifacts",

View File

@ -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");
});
});

View File

@ -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" },

View File

@ -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"),

View File

@ -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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>("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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>("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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>(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<HTMLButtonElement>(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 () => {

File diff suppressed because it is too large Load Diff